From 364fa3c24f68e646c8d9b10edb9f10e43dbd54ae Mon Sep 17 00:00:00 2001 From: flashwave Date: Fri, 13 Dec 2019 21:37:17 +0100 Subject: [PATCH] Router beginnings. --- composer.json | 5 +- composer.lock | 106 ++++++++++- misuzu.php | 17 +- msz.php | 9 + public/auth.php | 29 +-- public/index.php | 135 +++++--------- public/info.php | 64 +------ src/DB.php | 14 ++ src/Http/Handlers/AuthHandler.php | 16 ++ src/Http/Handlers/Handler.php | 15 ++ src/Http/Handlers/HomeHandler.php | 100 +++++++++++ src/Http/Handlers/InfoHandler.php | 65 +++++++ src/Http/HttpMessage.php | 116 ++++++++++++ src/Http/HttpRequestMessage.php | 86 +++++++++ src/Http/HttpResponseMessage.php | 112 ++++++++++++ src/Http/HttpServerRequestMessage.php | 160 +++++++++++++++++ src/Http/Routing/Route.php | 130 ++++++++++++++ src/Http/Routing/Router.php | 76 ++++++++ src/Http/Routing/RouterResponseMessage.php | 58 ++++++ src/Http/Stream.php | 194 +++++++++++++++++++++ src/Http/UploadedFile.php | 174 ++++++++++++++++++ src/Http/Uri.php | 183 +++++++++++++++++++ src/url.php | 2 +- 23 files changed, 1664 insertions(+), 202 deletions(-) create mode 100644 msz.php create mode 100644 src/Http/Handlers/AuthHandler.php create mode 100644 src/Http/Handlers/Handler.php create mode 100644 src/Http/Handlers/HomeHandler.php create mode 100644 src/Http/Handlers/InfoHandler.php create mode 100644 src/Http/HttpMessage.php create mode 100644 src/Http/HttpRequestMessage.php create mode 100644 src/Http/HttpResponseMessage.php create mode 100644 src/Http/HttpServerRequestMessage.php create mode 100644 src/Http/Routing/Route.php create mode 100644 src/Http/Routing/Router.php create mode 100644 src/Http/Routing/RouterResponseMessage.php create mode 100644 src/Http/Stream.php create mode 100644 src/Http/UploadedFile.php create mode 100644 src/Http/Uri.php diff --git a/composer.json b/composer.json index 702a33ad..7d35e8a9 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", + "ext-readline": "*", "ext-xml": "*", "ext-zip": "*", "twig/twig": "^2.0", @@ -22,7 +23,9 @@ "twig/extensions": "^1.5", "filp/whoops": "^2.2", "jublonet/codebird-php": "^3.1", - "chillerlan/php-qrcode": "^3.0" + "chillerlan/php-qrcode": "^3.0", + "psr/http-message": "^1.0", + "psr/http-server-handler": "^1.0" }, "autoload": { "classmap": [ diff --git a/composer.lock b/composer.lock index e735944b..ba60afca 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "400b588939ef5651adf2ca9ca06dd090", + "content-hash": "0649c35f94ec31b6b23ad77ce1fd744f", "packages": [ { "name": "chillerlan/php-qrcode", @@ -742,6 +742,109 @@ "homepage": "https://github.com/maxmind/web-service-common-php", "time": "2019-12-12T15:56:05+00:00" }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "time": "2018-10-30T16:46:14+00:00" + }, { "name": "psr/log", "version": "1.1.2", @@ -1280,6 +1383,7 @@ "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", + "ext-readline": "*", "ext-xml": "*", "ext-zip": "*" }, diff --git a/misuzu.php b/misuzu.php index 3555ffa6..c4c14b83 100644 --- a/misuzu.php +++ b/misuzu.php @@ -26,6 +26,9 @@ set_include_path(get_include_path() . PATH_SEPARATOR . MSZ_ROOT); require_once 'vendor/autoload.php'; +class_alias(\Misuzu\Http\HttpServerRequestMessage::class, '\Misuzu\Http\Handlers\Request'); +class_alias(\Misuzu\Http\Routing\RouterResponseMessage::class, '\Misuzu\Http\Handlers\Response'); + $errorHandler = new \Whoops\Run; $errorHandler->pushHandler( MSZ_CLI @@ -74,18 +77,7 @@ if(empty($dbConfig)) { $dbConfig = $dbConfig['Database'] ?? $dbConfig['Database.mysql-main'] ?? []; -DB::init(DB::buildDSN($dbConfig), $dbConfig['username'] ?? '', $dbConfig['password'] ?? '', [ - PDO::ATTR_CASE => PDO::CASE_NATURAL, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, - PDO::ATTR_STRINGIFY_FETCHES => false, - PDO::ATTR_EMULATE_PREPARES => false, - PDO::MYSQL_ATTR_INIT_COMMAND => " - SET SESSION - sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', - time_zone = '+00:00'; - ", -]); +DB::init(DB::buildDSN($dbConfig), $dbConfig['username'] ?? '', $dbConfig['password'] ?? '', DB::ATTRS); Config::init(); Mailer::init(Config::get('mail.method', Config::TYPE_STR), [ @@ -407,7 +399,6 @@ MIG; exit; } - // Remove this block at the start of April, 2 months is plenty for this to propagate if(!empty($_COOKIE['msz_uid']) && !empty($_COOKIE['msz_sid']) && ctype_digit($_COOKIE['msz_uid']) && ctype_xdigit($_COOKIE['msz_sid']) && strlen($_COOKIE['msz_sid']) === 64) { diff --git a/msz.php b/msz.php new file mode 100644 index 00000000..0af4197c --- /dev/null +++ b/msz.php @@ -0,0 +1,9 @@ +setInstance(); +$router->addRoutes( + // Home + Route::get('/', Handler::call('index@HomeHandler')), + + // Info + Route::get('/info', Handler::call('index@InfoHandler')), + Route::get('/info/([A-Za-z0-9_/]+)', true, Handler::call('page@InfoHandler')), + + // Redirects + Route::get('/index.php', Handler::redirect(url('index'), true)), + Route::get('/info.php', Handler::redirect(url('info'), true)), + Route::get('/info.php/([A-Za-z0-9_/]+)', true, Handler::call('redir@InfoHandler')), + Route::get('/auth.php', Handler::call('legacy@AuthHandler')) +); + +$response = $router->handle($request); +$response->setHeader('X-Powered-By', 'Misuzu'); + +$responseStatus = $response->getStatusCode(); + +header('HTTP/' . $response->getProtocolVersion() . ' ' . $responseStatus . ' ' . $response->getReasonPhrase()); + +foreach($response->getHeaders() as $headerName => $headerSet) + foreach($headerSet as $headerLine) + header("{$headerName}: {$headerLine}"); + +$responseBody = $response->getBody(); + +if($responseStatus >= 400 && $responseStatus <= 599 && ($responseBody === null || $responseBody->getSize() < 1)) { + echo render_error($responseStatus); +} else { + echo (string)$responseBody; } -if(Config::get('social.embed_linked', Config::TYPE_BOOL)) { - Template::set('linked_data', [ - 'name' => Config::get('site.name', Config::TYPE_STR, 'Misuzu'), - 'url' => Config::get('site.url', Config::TYPE_STR), - 'logo' => Config::get('site.ext_logo', Config::TYPE_STR), - 'same_as' => Config::get('social.linked', Config::TYPE_ARR), - ]); -} - -$news = news_posts_get(0, 5, null, true); - -$stats = DB::query(' - SELECT - ( - SELECT COUNT(`user_id`) - FROM `msz_users` - WHERE `user_deleted` IS NULL - ) AS `count_users_all`, - ( - SELECT COUNT(`user_id`) - FROM `msz_users` - WHERE `user_active` >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) - ) AS `count_users_online`, - ( - SELECT COUNT(`user_id`) - FROM `msz_users` - WHERE `user_active` >= DATE_SUB(NOW(), INTERVAL 24 HOUR) - ) AS `count_users_active`, - ( - SELECT COUNT(`comment_id`) - FROM `msz_comments_posts` - WHERE `comment_deleted` IS NULL - ) AS `count_comments`, - ( - SELECT COUNT(`topic_id`) - FROM `msz_forum_topics` - WHERE `topic_deleted` IS NULL - ) AS `count_forum_topics`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `post_deleted` IS NULL - ) AS `count_forum_posts` -')->fetch(); - -$changelog = DB::query(' - SELECT - `change_id`, `change_log`, `change_action`, - DATE(`change_created`) AS `change_date`, - !ISNULL(`change_text`) AS `change_has_text` - FROM `msz_changelog_changes` - ORDER BY `change_created` DESC - LIMIT 10 -')->fetchAll(); - -$birthdays = user_session_active() ? user_get_birthdays() : []; - -$latestUser = DB::query(' - SELECT - u.`user_id`, u.`username`, u.`user_created`, - COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` - FROM `msz_users` as u - LEFT JOIN `msz_roles` as r - ON r.`role_id` = u.`display_role` - WHERE `user_deleted` IS NULL - ORDER BY u.`user_id` DESC - LIMIT 1 -')->fetch(); - -$onlineUsers = DB::query(' - SELECT - u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` - FROM `msz_users` as u - LEFT JOIN `msz_roles` as r - ON r.`role_id` = u.`display_role` - WHERE u.`user_active` >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) - ORDER BY u.`user_active` DESC - LIMIT 104 -')->fetchAll(); - -Template::set([ - 'statistics' => $stats, - 'latest_user' => $latestUser, - 'online_users' => $onlineUsers, - 'birthdays' => $birthdays, - 'featured_changelog' => $changelog, - 'featured_news' => $news, -]); - -Template::render('home.landing'); diff --git a/public/info.php b/public/info.php index 98df3363..41cc616f 100644 --- a/public/info.php +++ b/public/info.php @@ -1,64 +1,2 @@ '', - 'title' => '', -]; - -$isMisuzuDoc = $pathInfo === '/misuzu' || starts_with($pathInfo, '/misuzu/'); - -if($isMisuzuDoc) { - $filename = substr($pathInfo, 8); - $filename = empty($filename) ? 'README' : strtoupper($filename); - - if($filename !== 'README') { - $titleSuffix = ' - Misuzu Project'; - } -} else { - $filename = strtolower(substr($pathInfo, 1)); -} - -if(!preg_match('#^([A-Za-z0-9_]+)$#', $filename)) { - echo render_error(404); - return; -} - -if($filename !== 'LICENSE') { - $filename .= '.md'; -} - -$filename = MSZ_ROOT . ($isMisuzuDoc ? '/' : '/docs/') . $filename; -$document['content'] = is_file($filename) ? file_get_contents($filename) : ''; - -if(empty($document['content'])) { - echo render_error(404); - return; -} - -if(empty($document['title'])) { - if(starts_with($document['content'], '# ')) { - $titleOffset = strpos($document['content'], "\n"); - $document['title'] = trim(substr($document['content'], 2, $titleOffset - 1)); - $document['content'] = substr($document['content'], $titleOffset); - } else { - $document['title'] = ucfirst(basename($filename)); - } - - if(!empty($titleSuffix)) { - $document['title'] .= $titleSuffix; - } -} - -Template::render('info.view', [ - 'document' => $document, -]); +require_once __DIR__ . '/index.php'; diff --git a/src/DB.php b/src/DB.php index fa21753d..94e68d77 100644 --- a/src/DB.php +++ b/src/DB.php @@ -1,11 +1,25 @@ PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => " + SET SESSION + sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', + time_zone = '+00:00'; + ", + ]; + public static function init(...$args) { self::$instance = new Database(...$args); } diff --git a/src/Http/Handlers/AuthHandler.php b/src/Http/Handlers/AuthHandler.php new file mode 100644 index 00000000..2a36a249 --- /dev/null +++ b/src/Http/Handlers/AuthHandler.php @@ -0,0 +1,16 @@ +getQueryParams(); + $mode = isset($query['m']) && is_string($query['m']) ? $query['m'] : ''; + $destination = [ + 'logout' => 'auth-logout', + 'reset' => 'auth-reset', + 'forgot' => 'auth-forgot', + 'register' => 'auth-register', + ][$mode] ?? 'auth-login'; + $response->redirect(url($destination), true); + } +} diff --git a/src/Http/Handlers/Handler.php b/src/Http/Handlers/Handler.php new file mode 100644 index 00000000..c4a1c2f0 --- /dev/null +++ b/src/Http/Handlers/Handler.php @@ -0,0 +1,15 @@ +redirect($location, $permanent); + }; + } +} diff --git a/src/Http/Handlers/HomeHandler.php b/src/Http/Handlers/HomeHandler.php new file mode 100644 index 00000000..986c060f --- /dev/null +++ b/src/Http/Handlers/HomeHandler.php @@ -0,0 +1,100 @@ + Config::get('site.name', Config::TYPE_STR, 'Misuzu'), + 'url' => Config::get('site.url', Config::TYPE_STR), + 'logo' => Config::get('site.ext_logo', Config::TYPE_STR), + 'same_as' => Config::get('social.linked', Config::TYPE_ARR), + ]; + } + + $news = news_posts_get(0, 5, null, true); + + $stats = DB::query(' + SELECT + ( + SELECT COUNT(`user_id`) + FROM `msz_users` + WHERE `user_deleted` IS NULL + ) AS `count_users_all`, + ( + SELECT COUNT(`user_id`) + FROM `msz_users` + WHERE `user_active` >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) + ) AS `count_users_online`, + ( + SELECT COUNT(`user_id`) + FROM `msz_users` + WHERE `user_active` >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + ) AS `count_users_active`, + ( + SELECT COUNT(`comment_id`) + FROM `msz_comments_posts` + WHERE `comment_deleted` IS NULL + ) AS `count_comments`, + ( + SELECT COUNT(`topic_id`) + FROM `msz_forum_topics` + WHERE `topic_deleted` IS NULL + ) AS `count_forum_topics`, + ( + SELECT COUNT(`post_id`) + FROM `msz_forum_posts` + WHERE `post_deleted` IS NULL + ) AS `count_forum_posts` + ')->fetch(); + + $changelog = DB::query(' + SELECT + `change_id`, `change_log`, `change_action`, + DATE(`change_created`) AS `change_date`, + !ISNULL(`change_text`) AS `change_has_text` + FROM `msz_changelog_changes` + ORDER BY `change_created` DESC + LIMIT 10 + ')->fetchAll(); + + $birthdays = user_session_active() ? user_get_birthdays() : []; + + $latestUser = DB::query(' + SELECT + u.`user_id`, u.`username`, u.`user_created`, + COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` + FROM `msz_users` as u + LEFT JOIN `msz_roles` as r + ON r.`role_id` = u.`display_role` + WHERE `user_deleted` IS NULL + ORDER BY u.`user_id` DESC + LIMIT 1 + ')->fetch(); + + $onlineUsers = DB::query(' + SELECT + u.`user_id`, u.`username`, + COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` + FROM `msz_users` as u + LEFT JOIN `msz_roles` as r + ON r.`role_id` = u.`display_role` + WHERE u.`user_active` >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) + ORDER BY u.`user_active` DESC + LIMIT 104 + ')->fetchAll(); + + $response->setTemplate('home.landing', [ + 'statistics' => $stats, + 'latest_user' => $latestUser, + 'online_users' => $onlineUsers, + 'birthdays' => $birthdays, + 'featured_changelog' => $changelog, + 'featured_news' => $news, + 'linked_data' => $linkedData ?? null, + ]); + } +} diff --git a/src/Http/Handlers/InfoHandler.php b/src/Http/Handlers/InfoHandler.php new file mode 100644 index 00000000..229ccc1f --- /dev/null +++ b/src/Http/Handlers/InfoHandler.php @@ -0,0 +1,65 @@ +setTemplate('info.index'); + } + + public function redir(Response $response, Request $request, string $name = ''): void { + $response->redirect(url('info', ['title' => $name]), true); + } + + public function page(Response $response, Request $request, string $name) { + $document = [ + 'content' => '', + 'title' => '', + ]; + + $isMisuzuDoc = $name === 'misuzu' || starts_with($name, 'misuzu/'); + + if($isMisuzuDoc) { + $filename = substr($name, 7); + $filename = empty($filename) ? 'README' : strtoupper($filename); + + if($filename !== 'README') { + $titleSuffix = ' - Misuzu Project'; + } + } else { + $filename = strtolower($name); + } + + if(!preg_match('#^([A-Za-z0-9_]+)$#', $filename)) { + return 404; + } + + if($filename !== 'LICENSE') { + $filename .= '.md'; + } + + $filename = MSZ_ROOT . ($isMisuzuDoc ? '/' : '/docs/') . $filename; + $document['content'] = is_file($filename) ? file_get_contents($filename) : ''; + + if(empty($document['content'])) { + return 404; + } + + if(empty($document['title'])) { + if(starts_with($document['content'], '# ')) { + $titleOffset = strpos($document['content'], "\n"); + $document['title'] = trim(substr($document['content'], 2, $titleOffset - 1)); + $document['content'] = substr($document['content'], $titleOffset); + } else { + $document['title'] = ucfirst(basename($filename)); + } + + if(!empty($titleSuffix)) { + $document['title'] .= $titleSuffix; + } + } + + $response->setTemplate('info.view', [ + 'document' => $document, + ]); + } +} diff --git a/src/Http/HttpMessage.php b/src/Http/HttpMessage.php new file mode 100644 index 00000000..2b512041 --- /dev/null +++ b/src/Http/HttpMessage.php @@ -0,0 +1,116 @@ +setProtocolVersion($version)->setHeaders($headers); + } + + public function getProtocolVersion() { + return $this->version; + } + public function setProtocolVersion(string $version): self { + $this->version = $version; + return $this; + } + public function withProtocolVersion($version) { + return (clone $this)->setProtocolVersion($version); + } + + public function getBody() { + return $this->body; + } + public function setBody(StreamInterface $body): self { + $this->body = $body; + return $this; + } + public function withBody(StreamInterface $stream) { + return (clone $this)->setBody($stream); + } + + public function getHeaders() { + return $this->headers; + } + public function setHeaders(array $headers): self { + foreach($headers as $name => $value) + $this->setHeader($name, $value); + return $this; + } + public function getHeaderName(string $name, bool $nullOnNone = false): ?string { + $lowerName = strtolower($name); + + foreach($this->headers as $headerName => $_) + if(strtolower($headerName) === $name) + return $headerName; + + return $nullOnNone ? null : $name; + } + public function hasHeader($name) { + return $this->getHeaderName($name, true) !== null; + } + public function getHeader($name) { + return $this->headers[$this->getHeaderName($name)] ?? []; + } + public function getHeaderLine($name) { + $header = $this->getHeader($name); + return implode(',', $header); + } + public function withHeader($name, $value) { + if(!is_string($name) || empty($name)) + throw new InvalidArgumentException('Header name must be a string.'); + + return (clone $this)->setHeader($name, $value); + } + public function setHeader(string $name, $value): self { + if(!($isString = is_string($value)) && !is_array($value)) + throw new InvalidArgumentException('Value must be of type string or array.'); + + $this->removeHeader($name); + $this->headers[$name] = $isString ? [$value] : $value; + + return $this; + } + public function withAddedHeader($name, $value) { + if(!is_string($name) || empty($name)) + throw new InvalidArgumentException('Header name must be a string.'); + + return (clone $this)->appendHeader($name, $value); + } + public function appendHeader(string $name, $value): self { + if(!($isString = is_string($value)) && !is_array($value)) + throw new InvalidArgumentException('Value must be of type string or array.'); + + $existingName = $this->getHeaderName($name, true); + $value = $isString ? [$value] : $value; + + if($existingName === null) { + $this->headers[$existingName] = $value; + } else { + $this->headers[$name] = array_merge($this->headers, $value); + } + + return $this; + } + public function withoutHeader($name) { + if(!is_string($name) || empty($name)) + throw new InvalidArgumentException('Header name must be a string.'); + + return (clone $this)->removeHeader($name); + } + public function removeHeader(string $name): self { + $name = $this->getHeaderName($name, true); + + if($name !== null) + unset($this->headers[$name]); + + return $this; + } +} diff --git a/src/Http/HttpRequestMessage.php b/src/Http/HttpRequestMessage.php new file mode 100644 index 00000000..227ae925 --- /dev/null +++ b/src/Http/HttpRequestMessage.php @@ -0,0 +1,86 @@ +setMethod($method); + $this->setUri($uri); + } + + public function getMethod() { + return $this->method; + } + public function setMethod(string $method): self { + if(empty($method)) + throw new InvalidArgumentException('Invalid method name.'); + $this->method = $method; + return $this; + } + public function withMethod($method) { + return (clone $this)->setMethod($method); + } + + public function getUri() { + return $this->uri; + } + public function setUri(UriInterface $uri, bool $preserveHost = false): self { + $this->uri = $uri; + + if(!$preserveHost || !$this->hasHeader('Host')) + $this->applyHostHeader(); + + return $this; + } + public function withUri($uri, $preserveHost = false) { + return (clone $this)->setUri($uri, $preserveHost); + } + + private function applyHostHeader(): void { + $uri = $this->getUri(); + + if(empty($host = $uri->getHost())) + return; + + if(($port = $uri->getPort()) !== null) + $host .= ":{$port}"; + + $headerName = $this->getHeaderName('Host'); + $this->headers = [$headerName => [$host]] + $this->headers; + } + + public function getRequestTarget() { + if($this->requestTarget !== null) + return $this->requestTarget; + + $uri = $this->getUri(); + $target = $uri->getPath(); + $query = $uri->getQuery(); + + if(empty($target)) + $target = '/'; + + if(!empty($query)) + $target .= '?' . $query; + + return $target; + } + public function setRequestTarget(?string $requestTarget): self { + if(preg_match('#\s#', $requestTarget)) + throw new InvalidArgumentException('Request target may not contain spaces.'); + + $this->requestTarget = $requestTarget; + return $this; + } + public function withRequestTarget($requestTarget) { + return (clone $this)->setRequestTarget($requestTarget); + } +} diff --git a/src/Http/HttpResponseMessage.php b/src/Http/HttpResponseMessage.php new file mode 100644 index 00000000..9750f8dd --- /dev/null +++ b/src/Http/HttpResponseMessage.php @@ -0,0 +1,112 @@ +setStatusCode($statusCode)->setReasonPhrase($reasonPhrase); + } + + public function getStatusCode() { + return $this->statusCode; + } + public function setStatusCode(int $code): self { + if($code < 100 || $code > 599) + throw new InvalidArgumentException('Invalid status code.'); + $this->statusCode = $code; + return $this; + } + public function getReasonPhrase() { + return !empty($this->reasonPhrase) ? $this->reasonPhrase : ( + array_key_exists($statusCode = $this->getStatusCode(), self::PHRASES) ? self::PHRASES[$statusCode] : '' + ); + } + public function setReasonPhrase(string $reasonPhrase): self { + $this->reasonPhrase = $reasonPhrase; + return $this; + } + public function withStatus($code, $reasonPhrase = '') { + return (clone $this)->setStatusCode($code)->setReasonPhrase($reasonPhrase); + } + + // https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + private const PHRASES = [ + // 1xx: Informational + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + + // 2xx: Success + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + + // 3xx: Redirection + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + + // 4xx: Client Error + 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', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Payload Too Large', + 414 => 'URI Too Long', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Too Early', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + + // 5xx: Server Error + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required', + ]; +} diff --git a/src/Http/HttpServerRequestMessage.php b/src/Http/HttpServerRequestMessage.php new file mode 100644 index 00000000..f8846d97 --- /dev/null +++ b/src/Http/HttpServerRequestMessage.php @@ -0,0 +1,160 @@ +setServerParams($serverParams); + $this->setQueryParams($queryParams); + $this->setUploadedFiles($uploadedFiles); + $this->setParsedBody($parsedBody); + $this->setBody($rawBody); + } + + public function getServerParams() { + return $this->server; + } + public function setServerParams(array $serverParams): self { + $this->server = $serverParams; + return $this; + } + + public function getCookieParams() { + return $this->cookies; + } + public function setCookieParams(array $cookies): self { + $this->cookies = $cookies; + return $this; + } + public function withCookieParams(array $cookies) { + return (clone $this)->setCookieParams($cookies); + } + + public function getQueryParams() { + return $this->query; + } + public function setQueryParams(array $query): self { + $this->query = $query; + return $this; + } + public function withQueryParams(array $query) { + return (clone $this)->setQueryParams($query); + } + + public function getUploadedFiles() { + return $this->files; + } + public function setUploadedFiles(array $uploadedFiles): self { + $this->files = $uploadedFiles; + return $this; + } + public function withUploadedFiles(array $uploadedFiles): self { + return (clone $this)->setUploadedFiles($uploadedFiles); + } + + public function getParsedBody() { + return $this->parsedBody; + } + public function setParsedBody($data): self { + if(!is_array($data) && !is_object($data) && $data !== null) + throw new InvalidArgumentException('Parsed body must by of type array, object or null.'); + $this->parsedBody = $data; + return $this; + } + public function withParsedBody($data) { + return (clone $this)->setParsedBody($data); + } + public function getBodyParam(string $name, ?string $default = null): ?string { + if(!is_array($this->parsedBody)) + return $default; + + return $this->parsedBody[$name] ?? $default; + } + + public function getAttributes() { + return $this->attributes; + } + public function getAttribute($name, $default = null) { + return $this->attributes[$name] ?? $default; + } + public function setAttribute(string $name, $value): self { + $this->attributes[$name] = $value; + return $this; + } + public function withAttribute($name, $value) { + return (clone $this)->setAttribute($name, $value); + } + public function removeAttribute(string $name): self { + unset($this->attributes[$name]); + return $this; + } + public function withoutAttribute($name) { + return (clone $this)->removeAttribute($name); + } + + private static function getRequestHeaders(): array { + if(function_exists('getallheaders')) + return getallheaders(); + + $headers = []; + + foreach($_SERVER as $key => $value) { + if(substr($key, 0, 5) === 'HTTP_') { + $key = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); + $reqHeaders[$key] = $value; + } elseif($key === 'CONTENT_TYPE') { + $reqHeaders['Content-Type'] = $value; + } elseif($key === 'CONTENT_LENGTH') { + $reqHeaders['Content-Length'] = $value; + } + } + + if(!isset($headers['Authorization'])) { + if(isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + $headers['Authorization'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } elseif(isset($_SERVER['PHP_AUTH_USER'])) { + $password = $_SERVER['PHP_AUTH_PW'] ?? ''; + $headers['Authorization'] = 'Basic ' . base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . $password); + } elseif(isset($_SERVER['PHP_AUTH_DIGEST'])) { + $headers['Authorization'] = $_SERVER['PHP_AUTH_DIGEST']; + } + } + + return $headers; + } + + public static function fromGlobals(): HttpServerRequestMessage { + return new static( + $_SERVER['REQUEST_METHOD'], + new Uri('/' . trim($_SERVER['REQUEST_URI'] ?? '', '/')), + $_SERVER, + self::getRequestHeaders(), + $_GET, + UploadedFile::createFromFILES($_FILES), + $_POST, + Stream::createFromFile('php://input') + ); + } +} diff --git a/src/Http/Routing/Route.php b/src/Http/Routing/Route.php new file mode 100644 index 00000000..9c537efb --- /dev/null +++ b/src/Http/Routing/Route.php @@ -0,0 +1,130 @@ +methods = array_map('strtoupper', $methods); + $this->regex = $regex; + $this->handler = $handler; + $this->path = $path; + } + + public static function create(array $methods, string $path, $regex = null, $callable = null): self { + return new static($methods, $path, is_bool($regex) ? $regex : false, is_bool($regex) ? $callable : $regex); + } + public static function get(string $path, $regex = null, $callable = null): self { + return self::create(['GET'], $path, $regex, $callable); + } + public static function post(string $path, $regex = null, $callable = null): self { + return self::create(['POST'], $path, $regex, $callable); + } + + public function isRegex(): bool { + return $this->regex; + } + public function setRegex(): self { + $this->regex = true; + foreach($this->children as $child) + $child->setRegex(); + return $this; + } + + public function getPath(): string { + return $this->path; + } + public function setPath(string $path): self { + $this->path = $path; + return $this; + } + + public function addFilters(string ...$filters): self { + $this->filters = array_merge($this->filters, $filters); + foreach($this->children as $child) + $child->addFilters(...$filters); + return $this; + } + public function getFilters(): array { + return $this->filters; + } + + public function getChildren(): array { + return $this->children; + } + public function addChildren(Route ...$routes): self { + foreach($routes as $route) { + $route->setPrefix($this->getPath())->addFilters(...$this->getFilters()); + + if($this->isRegex()) + $route->setRegex(); + + $this->children[] = $route; + } + return $this; + } + + public function setPrefix(string $prefix): self { + foreach($this->children as $child) + $child->setPrefix($prefix); + return $this->setPath($prefix . '/' . trim($this->getPath(), '/')); + } + + public function match(RequestInterface $request, array &$matches): bool { + $matches = [$this->getPath()]; + + if(!in_array($request->getMethod(), $this->methods)) + return false; + + $requestPath = $request->getUri()->getPath(); + + return $this->isRegex() + ? preg_match('#^' . $this->getPath() . '$#', $requestPath, $matches) + : $this->getPath() === $requestPath; + } + + public function dispatch(ServerRequestInterface $request, ...$args): ResponseInterface { + $response = new RouterResponseMessage(200); + $result = null; + + array_unshift($args, $response, $request); + + if(is_array($this->handler)) { + if(method_exists($this->handler[0] ?? '', $this->handler[1] ?? '')) { + $handlerClass = new $this->handler[0]($response, $request); + $result = $handlerClass->{$this->handler[1]}(...$args); + } + } elseif(is_callable($this->handler)) { + $result = call_user_func_array($this->handler, $args); + } + + if($result !== null) { + $resultType = gettype($result); + + switch($resultType) { + case 'array': + case 'object': + $response->setJson($result); + break; + case 'integer': + $response->setStatusCode($result); + break; + default: + $response->setHtml($result); + break; + } + } + + return $response; + } +} diff --git a/src/Http/Routing/Router.php b/src/Http/Routing/Router.php new file mode 100644 index 00000000..fe1bc67f --- /dev/null +++ b/src/Http/Routing/Router.php @@ -0,0 +1,76 @@ +{$name}(...$args); + } + + public function __construct() {} + + public function setInstance(): self { + return self::$instance = $this; + } + + public function addRoutes(Route ...$routes): self { + foreach($routes as $route) { + if($route->isRegex()) { + $this->routesRegex[] = $route; + } else { + $this->routesExact[] = $route; + } + + $this->addRoutes(...$route->getChildren()); + } + + return $this; + } + + private function matchExact(RequestInterface $request, array &$matches): ?Route { + foreach($this->routesExact as $route) + if($route->match($request, $matches)) + return $route; + return null; + } + + private function matchRegex(RequestInterface $request, array &$matches): ?Route { + foreach($this->routesRegex as $route) + if($route->match($request, $matches)) + return $route; + return null; + } + + public function match(RequestInterface $request, array &$matches): ?Route { + return $this->matchExact($request, $matches) ?? $this->matchRegex($request, $matches); + } + + public function handle(ServerRequestInterface $request): ResponseInterface { + $matches = []; + $route = $this->match($request, $matches); + + if($route === null) + return new HttpResponseMessage(404); + + foreach($route->getFilters() as $filter) { + $response = (new $filter)->process($request, $this); + + if($response !== null) + return $response; + } + + $response = $route->dispatch($request, ...array_slice($matches, 1)); + + return $response; + } +} diff --git a/src/Http/Routing/RouterResponseMessage.php b/src/Http/Routing/RouterResponseMessage.php new file mode 100644 index 00000000..77d6ce12 --- /dev/null +++ b/src/Http/Routing/RouterResponseMessage.php @@ -0,0 +1,58 @@ +getHeaderLine('Content-Type'); + } + public function setContentType(string $type): self { + $this->setHeader('Content-Type', $type); + return $this; + } + + public function setText(string $text): self { + $body = $this->getBody(); + + if($body !== null) + $body->close(); + + $this->setBody(Stream::create($text)); + return $this; + } + public function appendText(string $text): self { + $body = $this->getBody(); + + if($body === null) + $this->setBody(Stream::create($text)); + else + $body->write($text); + return $this; + } + public function setHtml(string $content, bool $html = true): self { + $this->setContentType('text/html; charset=utf-8'); + $this->setText($content); + return $this; + } + public function setTemplate(string $file, array $vars = []): self { + return $this->setHtml(Template::renderRaw($file, $vars)); + } + public function setJson($content, int $options = 0): self { + $this->setContentType('application/json; charset=utf-8'); + $this->setText(json_encode($content, $options)); + return $this; + } + + public function redirect(string $path, bool $permanent = false): self { + $this->setStatusCode($permanent ? 301 : 302); + $this->setHeader('Location', $path); + return $this; + } + + public function getContents(): string { + return (string)($this->getBody() ?? ''); + } +} diff --git a/src/Http/Stream.php b/src/Http/Stream.php new file mode 100644 index 00000000..e8a3b613 --- /dev/null +++ b/src/Http/Stream.php @@ -0,0 +1,194 @@ +stream = $resource; + $metaData = $this->getMetadata(); + + $this->uri = $metaData['uri'] ?? null; + $this->readable = in_array($metaData['mode'], self::READABLE); + $this->writable = in_array($metaData['mode'], self::WRITABLE); + $this->seekable = $metaData['seekable'] && fseek($this->stream, 0, SEEK_CUR) === 0; + } + + public static function create($contents = ''): StreamInterface { + if($contents instanceof StreamInterface) + return $contents; + + return new static($contents); + } + public static function createFromFile(string $filename, string $mode = 'rb'): StreamInterface { + if(!in_array($mode[0], ['r', 'w', 'a', 'x', 'c'])) + throw new InvalidArgumentException("Provided mode ({$mode}) is invalid."); + + $file = @fopen($filename, $mode); + + if($file === false) + throw new RuntimeException("Wasn't able to open '{$filename}'."); + + return self::create($file); + } + + public function getMetadata($key = null) { + $hasKey = $key !== null; + + if(!isset($this->stream)) + return $hasKey ? null : []; + + $metaData = stream_get_meta_data($this->stream); + + if(!$hasKey) + return $metaData; + + return $metaData[$key] ?? null; + } + + public function isReadable() { + return $this->readable; + } + + public function read($length) { + if(!$this->isReadable()) + throw RuntimeException('Can\'t read from this stream.'); + + return fread($this->stream, $length); + } + + public function getContents() { + if(!isset($this->stream)) + throw new RuntimeException('Can\'t read contents of stream.'); + + if(($contents = stream_get_contents($this->stream)) === false) + throw new RuntimeException('Failed to read contents of stream.'); + + return $contents; + } + + public function isWritable() { + return $this->writable; + } + + public function write($string) { + if(!$this->isWritable()) + throw new RuntimeException('Can\'t write to this stream.'); + + $this->size = null; + + if(($count = fwrite($this->stream, $string)) === false) + throw new RuntimeException('Failed to write to this stream.'); + + return $count; + } + + public function isSeekable() { + return $this->seekable; + } + + public function seek($offset, $whence = SEEK_SET) { + if(!$this->isSeekable()) + throw new RuntimeException('Can\'t seek in this stream.'); + + if(fseek($this->stream, $offset, $whence) === -1) + throw new RuntimeException("Failed to seek to position {$offset} ({$whence})."); + } + + public function rewind() { + $this->seek(0); + } + + public function tell() { + if(!isset($this->stream)) + throw new RuntimeException('Can\'t determine the position of a detached stream.'); + if(($pos = ftell($this->stream)) === false) + throw new RuntimeException('Can\'t tell position in stream.'); + + return $pos; + } + + public function eof() { + return !isset($this->stream) || feof($this->stream); + } + + public function getSize() { + if($this->size !== null) + return $this->size; + + if(!isset($this->stream)) + return null; + + if(!empty($this->uri)) + clearstatcache($this->uri); + + $stats = fstat($this->stream); + if(isset($stats['size'])) + return $this->size = $stats['size']; + + return null; + } + + public function detach() { + if(!isset($this->stream)) + return null; + + $stream = $this->stream; + $this->stream = $this->size = $this->uri = null; + $this->readable = $this->writable = $this->seekable = false; + + return $stream; + } + + public function close() { + $stream = $this->detach(); + + if(is_resource($stream)) + fclose($stream); + } + + public function __toString() { + try { + if($this->isSeekable()) + $this->rewind(); + + return $this->getContents(); + } catch(Exception $ex) { + return ''; + } + } + + public function __destruct() { + $this->close(); + } +} diff --git a/src/Http/UploadedFile.php b/src/Http/UploadedFile.php new file mode 100644 index 00000000..ef5eb156 --- /dev/null +++ b/src/Http/UploadedFile.php @@ -0,0 +1,174 @@ +error = $error; + $this->size = $size; + $this->clientFileName = $clientName; + $this->clientMimeType = $clientType; + + if($error === UPLOAD_ERR_OK) { + if($fileNameOrStream === null) + throw new InvalidArgumentException('No stream or filename provided.'); + + if(is_string($fileNameOrStream)) + $this->fileName = $fileNameOrStream; + elseif(is_resource($fileNameOrStream)) + $this->stream = Stream::create($fileNameOrStream); + elseif($fileNameOrStream instanceof StreamInterface) + $this->stream = $fileNameOrStream; + + if($size === null && $this->stream !== null) + $this->size = $stream->getSize(); + } + } + + public function getStream() { + if($this->getError() !== UPLOAD_ERR_OK) + throw new RuntimeException('Can\'t open stream because of an upload error.'); + if($this->hasMoved) + throw new RuntimeException('Can\'t open stream because file has already been moved.'); + if($this->steam === null) + $this->stream = Stream::createFromFile($this->fileName); + + return $this->stream; + } + + public function moveTo($targetPath) { + if($this->getError() !== UPLOAD_ERR_OK) + throw new RuntimeException('Can\'t move file because of an upload error.'); + if($this->hasMoved) + throw new RuntimeException('This uploaded file has already been moved.'); + if(!is_string($targetPath) || empty($targetPath)) + throw new InvalidArgumentException('$targetPath is not a valid path.'); + + if($this->fileName !== null) { + $this->hasMoved = PHP_SAPI === 'CLI' + ? rename($this->fileName, $targetPath) + : move_uploaded_file($this->fileName, $targetPath); + } else { + $stream = $this->getStream(); + + if($stream->isSeekable()) + $stream->rewind(); + + $target = Stream::createFromFile($targetPath, 'wb'); + while(!$stream->eof() && $target->write($stream->read(0x100000)) > 0); + $this->hasMoved = true; + } + + if(!$this->hasMoved) + throw new RuntimeException('Failed to move file to ' . $targetPath); + } + + public function getSize() { + return $this->size; + } + + public function getError() { + return $this->error; + } + + public function getClientFilename() { + return $this->clientFileName; + } + + public function getClientMediaType() { + return $this->clientMimeType; + } + + public static function createFromFILE(array $file): self { + return new static( + $file['tmp_name'] ?? '', + $file['error'] ?? UPLOAD_ERR_NO_FILE, + $file['size'] ?? null, + $file['name'] ?? null, + $file['type'] ?? null + ); + } + + private static function traverseFILES(array $files, string $keyName): array { + $arr = []; + + foreach($files as $key => $val) { + $key = "_{$key}"; + + if(is_array($val)) { + $arr[$key] = self::traverseFILES($val, $keyName); + } else { + $arr[$key][$keyName] = $val; + } + } + + return $arr; + } + + private static function normalizeFILES(array $files): array { + $out = []; + + foreach($files as $key => $arr) { + if(empty($arr)) + continue; + + $key = '_' . $key; + + if(is_int($arr['error'])) { + $out[$key] = $arr; + continue; + } + + if(is_array($arr['error'])) { + $keys = array_keys($arr); + + foreach($keys as $keyName) { + $out[$key] = array_merge_recursive($out[$key] ?? [], self::traverseFILES($arr[$keyName], $keyName)); + } + continue; + } + } + + return $out; + } + + private static function createObjectInstances(array $files): array { + $coll = []; + + foreach($files as $key => $val) { + $key = substr($key, 1); + + if(isset($val['error'])) { + $coll[$key] = self::createFromFILE($val); + } else { + $coll[$key] = self::createObjectInstances($val); + } + } + + return $coll; + } + + public static function createFromFILES(array $files): array { + if(empty($files)) + return []; + + return self::createObjectInstances(self::normalizeFILES($files)); + } +} diff --git a/src/Http/Uri.php b/src/Http/Uri.php new file mode 100644 index 00000000..43842d1f --- /dev/null +++ b/src/Http/Uri.php @@ -0,0 +1,183 @@ +originalString = $uriString; + + if(!empty($uriString)) { + $uri = parse_url($uriString); + + if($uri === false) + throw new InvalidArgumentException('URI cannot be parsed.'); + + $this->setScheme($uri['scheme'] ?? ''); + $this->setUserInfo($uri['user'] ?? '', $uri['pass'] ?? null); + $this->setHost($uri['host'] ?? ''); + $this->setPort($uri['port'] ?? null); + $this->setPath($uri['path'] ?? ''); + $this->setQuery($uri['query'] ?? ''); + $this->setFragment($uri['fragment'] ?? ''); + } + } + + public function getOriginalString(): string { + return $this->originalString; + } + + public function getScheme() { + return $this->scheme; + } + public function setScheme(string $scheme): self { + $this->scheme = $scheme; + return $this; + } + public function withScheme($scheme) { + if(!is_string($scheme)) + throw new InvalidArgumentException('Scheme must be a string.'); + + return (clone $this)->setScheme($scheme); + } + + public function getAuthority() { + $authority = ''; + + if(!empty($userInfo = $this->getUserInfo())) + $authority .= $userInfo . '@'; + + $authority .= $this->getHost(); + + if(($port = $this->getPort()) !== null) + $authority .= ':' . $port; + + return $authority; + } + + public function getUserInfo() { + $userInfo = $this->user; + + if(!empty($this->password)) + $userInfo .= ':' . $this->password; + + return $userInfo; + } + public function setUserInfo(string $user, ?string $password = null): self { + $this->user = $user; + $this->password = $password; + return $this; + } + public function withUserInfo($user, $password = null) { + return (clone $this)->setUserInfo($user, $password); + } + + public function getHost() { + return $this->host; + } + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + public function withHost($host) { + if(!is_string($host)) + throw new InvalidArgumentException('Hostname must be a string.'); + + return (clone $this)->setHost($host); + } + + public function getPort() { + return $this->port; + } + public function setPort(?int $port): self { + if($port !== null && ($port < 1 || $port > 0xFFFF)) + throw new InvalidArgumentException('Invalid port.'); + + $this->port = $port; + return $this; + } + public function withPort($port) { + return (clone $this)->setPort($port); + } + + public function getPath() { + return $this->path; + } + public function setPath(string $path): self { + $this->path = $path; + return $this; + } + public function withPath($path) { + if(!is_string($path)) + throw new InvalidArgumentException('Path must be a string.'); + + return (clone $this)->setPath($path); + } + + public function getQuery() { + return $this->query; + } + public function setQuery(string $query): self { + $this->query = $query; + return $this; + } + public function withQuery($query) { + if(!is_string($query)) + throw new InvalidArgumentException('Query string must be a string.'); + + return (clone $this)->setQuery($query); + } + + public function getFragment() { + return $this->fragment; + } + public function setFragment(string $fragment): self { + $this->fragment = $fragment; + return $this; + } + public function withFragment($fragment) { + return (clone $this)->setFragment($fragment); + } + + public function __toString() { + $string = ''; + + if(!empty($scheme = $this->getScheme())) + $string .= $scheme . ':'; + + $authority = $this->getAuthority(); + $hasAuthority = !empty($authority); + + if($hasAuthority) + $string .= '//' . $authority; + + $path = $this->getPath(); + $hasPath = !empty($path); + + if($hasAuthority && (!$hasPath || $path[0] !== '/')) + $string .= '/'; + elseif(!$hasAuthority && $path[1] === '/') + $path = '/' . trim($path, '/'); + + $string .= $path; + + if(!empty($query = $this->getQuery())) + $string .= '?' . $query; + + if(!empty($fragment = $this->getFragment())) + $string .= '#' . $fragment; + + return $string; + } +} diff --git a/src/url.php b/src/url.php index c3f446c1..f53cdf3f 100644 --- a/src/url.php +++ b/src/url.php @@ -9,7 +9,7 @@ // text surrounded by { and } will be replaced by a CSRF token with the given text as its realm, this will have no effect in a sessionless environment define('MSZ_URLS', [ 'index' => ['/'], - 'info' => ['/info.php/'], + 'info' => ['/info/<title>'], 'media-proxy' => ['/proxy.php/<hash>/<url>'], 'search-index' => ['/search.php'],