From 50b78f8131f7a63e37bb78fa4fa959c78860d644 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Tue, 3 Dec 2024 21:00:07 -0600 Subject: [PATCH] Significant refactors, add middleware to router --- database/create/auth.sql | 2 +- database/manage.sh | 2 +- public/index.php | 126 ++++-------------------------------- src/actions/auth.php | 76 ++++++++++++++++++++++ src/auth.php | 72 --------------------- src/bootstrap.php | 77 +++++++++++++++------- src/database.php | 127 ++++++++++++++++++------------------- src/helpers.php | 134 +++++++++++++++++++-------------------- src/router.php | 29 +++++++-- 9 files changed, 297 insertions(+), 348 deletions(-) create mode 100644 src/actions/auth.php delete mode 100644 src/auth.php diff --git a/database/create/auth.sql b/database/create/auth.sql index 41be045..2f9052f 100644 --- a/database/create/auth.sql +++ b/database/create/auth.sql @@ -17,7 +17,7 @@ CREATE TABLE sessions ( `token` TEXT NOT NULL UNIQUE, `expires` INTEGER NOT NULL ); -CREATE INDEX idx_sessions_user_id ON sessions (`user_id`); +CREATE INDEX idx_sessions_token ON sessions (`token`); DROP TABLE IF EXISTS tokens; CREATE TABLE tokens ( diff --git a/database/manage.sh b/database/manage.sh index beb21de..e068467 100755 --- a/database/manage.sh +++ b/database/manage.sh @@ -72,6 +72,6 @@ case $1 in drop_db ;; *) - echo "Usage: $0 {create|populate|reset}" + echo "Usage: $0 {create|populate|reset|drop}" ;; esac diff --git a/public/index.php b/public/index.php index 3924c3c..ac85b78 100644 --- a/public/index.php +++ b/public/index.php @@ -20,85 +20,14 @@ $r->get('/', function() { /* Auth */ -$r->get('/register', function() { - guest_only(); - echo render('layouts/basic', ['view' => 'pages/auth/register']); -}); - -$r->post('/register', function() { - guest_only(); - csrf_ensure(); - - $errors = []; - - $u = trim($_POST['u'] ?? ''); - $e = trim($_POST['e'] ?? ''); - $p = $_POST['p'] ?? ''; - - /* - A username is required. - A username must be at least 3 characters long and at most 18 characters long. - A username must contain only alphanumeric characters and spaces. - */ - if (empty($u) || strlen($u) < 3 || strlen($u) > 18 || !ctype_alnum(str_replace(' ', '', $u))) { - $errors['u'][] = 'Username is required and must be between 3 and 18 characters long and contain only - alphanumeric characters and spaces.'; - } - - /* - An email is required. - An email must be at most 255 characters long. - An email must be a valid email address. - */ - if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) { - $errors['e'][] = 'Email is required must be a valid email address.'; - } - - /* - A password is required. - A password must be at least 6 characters long. - */ - if (empty($p) || strlen($p) < 6) { - $errors['p'][] = 'Password is required and must be at least 6 characters long.'; - } - - /* - A username must be unique. - */ - if (User::username_exists($u)) { - $errors['u'][] = 'Username is already taken.'; - } - - /* - An email must be unique. - */ - if (User::email_exists($e)) { - $errors['e'][] = 'Email is already taken.'; - } - - // If there are errors at this point, send them to the page with errors flashed. - if (!empty($errors)) { - $GLOBALS['form-errors'] = $errors; - echo render('layouts/basic', ['view' => 'pages/auth/register']); - exit; - } - - if (User::create($u, $e, $p) === false) error_response(400); - - $_SESSION['user'] = serialize(User::find($u)); - Wallet::create(user()->id); - redirect('/character/create-first'); -}); +$r->get('/register', 'Actions\Auth::register_get')->middleware('guest_only'); +$r->post('/register', 'Actions\Auth::register_post')->middleware('guest_only'); $r->get('/login', function() { - guest_only(); echo render('layouts/basic', ['view' => 'pages/auth/login']); -}); +})->middleware('guest_only'); $r->post('/login', function() { - guest_only(); - csrf_ensure(); - $errors = []; $u = trim($_POST['u'] ?? ''); @@ -138,10 +67,9 @@ $r->post('/login', function() { } redirect('/'); -}); +})->middleware('guest_only'); $r->post('/logout', function() { - csrf_ensure(); Session::delete(user()->id); unset($_SESSION['user']); set_cookie('remember_me', '', 1); @@ -159,18 +87,10 @@ $r->get('/debug/logout', function() { Characters */ $r->get('/characters', function() { - auth_only_and_must_have_character(); - - $GLOBALS['active_nav_tab'] = 'chars'; //echo page('chars/list', ['chars' => user()->char_list()]); -}); +})->middleware('must_have_character'); $r->post('/characters', function() { - auth_only_and_must_have_character(); - csrf_ensure(); - - $GLOBALS['active_nav_tab'] = 'chars'; - $char_id = (int) ($_POST['char_id'] ?? 0); $action = $_POST['action'] ?? ''; @@ -210,24 +130,16 @@ $r->post('/characters', function() { } redirect('/characters'); -}); +})->middleware('must_have_character'); $r->get('/character/create-first', function() { - auth_only(); - - $GLOBALS['active_nav_tab'] = 'chars'; - // If the user already has a character, redirect them to the main page. if (user()->char_count() > 0) redirect('/'); //echo page('chars/first'); -}); +})->middleware('auth_only'); $r->post('/character/create', function() { - auth_only(); csrf_ensure(); - - $GLOBALS['active_nav_tab'] = 'chars'; - $errors = []; $name = trim($_POST['n'] ?? ''); @@ -275,12 +187,9 @@ $r->post('/character/create', function() { flash('alert_character_list_1', ['success', 'Character ' . $name . ' created!']); redirect('/characters'); -}); +})->middleware('auth_only'); $r->post('/character/delete', function() { - auth_only_and_must_have_character(); - csrf_ensure(); - $char_id = (int) ($_POST['char_id'] ?? 0); // If the character ID is not a number, return a 400. @@ -308,15 +217,14 @@ $r->post('/character/delete', function() { flash('alert_character_list_1', ['danger', 'Character ' . $char['name'] . ' deleted.']); redirect('/characters'); -}); +})->middleware('must_have_character'); /* World */ $r->get('/world', function() { - auth_only_and_must_have_character(); echo render('layouts/game'); -}); +})->middleware('must_have_character'); $r->post('/move', function() { /* @@ -328,8 +236,6 @@ $r->post('/move', function() { data to move them, we can just get and update their lcoation using the user's currently selected character ID. */ - ajax_only(); auth_only(); csrf_ensure(); - define('directions', [ [0, -1], // Up [0, 1], // Down @@ -361,28 +267,24 @@ $r->post('/move', function() { if ($r === false) throw new Exception('Failed to move character. (wcmp)'); json_response(['x' => $x, 'y' => $y]); -}); +})->middleware('ajax_only')->middleware('must_have_character'); /* UI */ $r->get('/ui/stats', function() { - ui_guard(); echo c_profile_stats(char()); -}); +})->middleware('ajax_only')->middleware('must_have_character'); /* Router */ -// [code, handler, params] -stopwatch_start('router'); +// [code, handler, params, middleware] $l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); -stopwatch_stop('router'); -stopwatch_start('handler'); if ($l['code'] !== 200) error_response($l['code']); +if (!empty($l['middleware'])) foreach ($l['middleware'] as $middleware) $middleware(); $l['handler'](...$l['params'] ?? []); -stopwatch_stop('handler'); /* Cleanup diff --git a/src/actions/auth.php b/src/actions/auth.php new file mode 100644 index 0000000..af17251 --- /dev/null +++ b/src/actions/auth.php @@ -0,0 +1,76 @@ + 'pages/auth/register']); + } + + public static function register_post(): void + { + $errors = []; + + $u = trim($_POST['u'] ?? ''); + $e = trim($_POST['e'] ?? ''); + $p = $_POST['p'] ?? ''; + + /* + A username is required. + A username must be at least 3 characters long and at most 18 characters long. + A username must contain only alphanumeric characters and spaces. + */ + if (empty($u) || strlen($u) < 3 || strlen($u) > 18 || !ctype_alnum(str_replace(' ', '', $u))) { + $errors['u'][] = 'Username is required and must be between 3 and 18 characters long and contain only + alphanumeric characters and spaces.'; + } + + /* + An email is required. + An email must be at most 255 characters long. + An email must be a valid email address. + */ + if (empty($e) || strlen($e) > 255 || !filter_var($e, FILTER_VALIDATE_EMAIL)) { + $errors['e'][] = 'Email is required must be a valid email address.'; + } + + /* + A password is required. + A password must be at least 6 characters long. + */ + if (empty($p) || strlen($p) < 6) { + $errors['p'][] = 'Password is required and must be at least 6 characters long.'; + } + + /* + A username must be unique. + */ + if (User::username_exists($u)) { + $errors['u'][] = 'Username is already taken.'; + } + + /* + An email must be unique. + */ + if (User::email_exists($e)) { + $errors['e'][] = 'Email is already taken.'; + } + + // If there are errors at this point, send them to the page with errors flashed. + if (!empty($errors)) { + $GLOBALS['form-errors'] = $errors; + echo render('layouts/basic', ['view' => 'pages/auth/register']); + exit; + } + + if (\User::create($u, $e, $p) === false) error_response(400); + + $_SESSION['user'] = serialize(\User::find($u)); + \Wallet::create(user()->id); + redirect('/character/create-first'); + } +} diff --git a/src/auth.php b/src/auth.php deleted file mode 100644 index 2de78c2..0000000 --- a/src/auth.php +++ /dev/null @@ -1,72 +0,0 @@ -validate()) { - $user = User::find($session->user_id); - $_SESSION['user'] = serialize($user); - return true; - } - } - - return false; -} - -/** - * Ensure a user is logged in, or redirect to the login page. This will also check for a remember me cookie and - * populate the $_SESSION['user'] array. - */ -function auth_only() -{ - if (!auth_check()) redirect('/auth/login'); -} - -/** - * If there is a user logged in, redirect to the home page. Used for when we have a guest-only page. - */ -function guest_only() -{ - if (auth_check()) redirect('/'); -} - -/** - * Ensure the user has a character selected. If they have no character, redirect to the character creation page. Otherwise, - * select the first character attached to the user. - */ -function must_have_character() -{ - // If there is a character selected, make sure the session is up to date. - if (user()->char_id !== 0) { - char(); - return; - } - - // if no characters, redirect to create first - if (user()->char_count() === 0) redirect('/character/create-first'); - - // if no character selected, select the first one - if (user()->char_id === 0) { - $char = live_db()->query( - 'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1', - [':u' => user()->id] - )->fetchArray(SQLITE3_ASSOC); - change_user_character($char['id']); - } -} - -/** - * The user must be authenticated and have a character. - */ -function auth_only_and_must_have_character() -{ - auth_only(); - must_have_character(); -} diff --git a/src/bootstrap.php b/src/bootstrap.php index 2765598..5ff6384 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -1,24 +1,37 @@ '/database.php', - 'Router' => '/router.php', - 'User' => '/models/user.php', - 'Character' => '/models/character.php', - 'Wallet' => '/models/wallet.php', - 'Session' => '/models/session.php', -]); - +/* + ============================================================ + Libraries + ============================================================ +*/ require_once SRC . '/helpers.php'; -require_once SRC . 'auth.php'; -require_once SRC . 'components.php'; -require_once SRC . 'enums.php'; +require_once SRC . '/components.php'; +require_once SRC . '/enums.php'; -require_once SRC . '/models/token.php'; +/* + ============================================================ + PHP Sessions + ============================================================ +*/ +session_start(); + +/* + ============================================================ + Autoloading + ============================================================ +*/ +define('CLASS_MAP', [ + 'Database' => '/database.php', + 'Router' => '/router.php', + 'User' => '/models/user.php', + 'Character' => '/models/character.php', + 'Wallet' => '/models/wallet.php', + 'Session' => '/models/session.php', + 'Actions\Auth' => '/actions/auth.php', +]); spl_autoload_register(function (string $class) { if (array_key_exists($class, CLASS_MAP)) require_once SRC . CLASS_MAP[$class]; @@ -28,21 +41,39 @@ spl_autoload_register(function (string $class) { define('START_TIME', microtime(true)); /* - Load env, set error reporting, etc. + ============================================================ + .ENV + ============================================================ */ env_load(SRC . '/../.env'); -if (env('debug') === 'true') { +if (env('debug', false)) { ini_set('display_errors', '1'); ini_set('display_startup_errors', '1'); error_reporting(E_ALL); } -// Create an array in GLOBALS to hold database connections. -$GLOBALS['databases'] = []; +/* + ============================================================ + CSRF + ============================================================ +*/ +csrf(); // generate a CSRF token, or retrieve the current token -// Generate a new CSRF token. (if one doesn't exist, that is) -csrf(); +// error any request that fails CSRF on these methods +if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH', 'DELETE'])) { + $csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? ''; + if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418); +} -// Run auth_check to see if we're logged in, since it populates the user data in SESSION -auth_check(); +/* + ============================================================ + Global State + ============================================================ +*/ +$GLOBALS['databases'] = []; // database interfaces + +// all relevant state to handling requests +$GLOBALS['state'] = [ + 'logged_in' => isset($_SESSION['user']) || validate_session() +]; diff --git a/src/database.php b/src/database.php index 05078de..c2a4859 100644 --- a/src/database.php +++ b/src/database.php @@ -1,81 +1,78 @@ exec('PRAGMA cache_size = 32000'); - // Enable WAL mode - $db->exec('PRAGMA journal_mode = WAL'); - // Move temp store to memory - $db->exec('PRAGMA temp_store = MEMORY'); + parent::exec('PRAGMA cache_size = 32000'); + parent::exec('PRAGMA journal_mode = WAL'); + parent::exec('PRAGMA temp_store = MEMORY'); + } - $this->db = $db; - } + public function query(string $query, array $params = []): SQLite3Result|false + { + $p = strpos($query, '?') !== false; + $stmt = $this->prepare($query); - public function query(string $query, array $params = []): SQLite3Result|false - { - $p = strpos($query, '?') !== false; // generic placeholders? - $stmt = $this->db->prepare($query); - if (!empty($params)) { - foreach ($params as $k => $v) $stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v)); - } - $start = microtime(true); - $r = $stmt->execute(); - $this->log($query, microtime(true) - $start); - return $r; - } + if (!empty($params)) { + foreach ($params as $k => $v) { + $stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v)); + } + } - public function exec(string $query): bool - { - $start = microtime(true); - $r = $this->db->exec($query); - $this->log($query, microtime(true) - $start); - return $r; - } + $start = microtime(true); + $r = $stmt->execute(); + $this->log($query, microtime(true) - $start); - public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool - { - if ($case_insensitive) { - $query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1"; - } else { - $query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1"; - } + return $r; + } - $result = $this->query($query, [':v' => $value]); - return $result->fetchArray(SQLITE3_NUM) !== false; - } + public function exec(string $query): bool + { + $start = microtime(true); + $r = parent::exec($query); + $this->log($query, microtime(true) - $start); + return $r; + } - private function log(string $query, float $time_taken): void - { - $this->count++; - $this->query_time += $time_taken; - if (env('debug', false)) $this->log[] = [$query, $time_taken]; - } + public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool + { + if ($case_insensitive) { + $query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1"; + } else { + $query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1"; + } - private function getSQLiteType(mixed $value): int - { - return match (true) { - is_int($value) => SQLITE3_INTEGER, - is_float($value) => SQLITE3_FLOAT, - is_null($value) => SQLITE3_NULL, - default => SQLITE3_TEXT - }; - } + $result = $this->query($query, [':v' => $value]); + return $result->fetchArray(SQLITE3_NUM) !== false; + } - public function lastInsertRowID(): int - { - return $this->db->lastInsertRowID(); - } + private function log(string $query, float $time_taken): void + { + $this->count++; + $this->query_time += $time_taken; + + if (env('debug', false)) { + $this->log[] = [$query, $time_taken]; + } + } + + private function getSQLiteType(mixed $value): int + { + return match (true) { + is_int($value) => SQLITE3_INTEGER, + is_float($value) => SQLITE3_FLOAT, + is_null($value) => SQLITE3_NULL, + default => SQLITE3_TEXT + }; + } } diff --git a/src/helpers.php b/src/helpers.php index 69e6332..64dcae8 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -3,7 +3,7 @@ /** * Load the environment variables from the .env file. */ -function env_load($filePath) +function env_load(string $filePath): void { if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)"); @@ -34,7 +34,7 @@ function env_load($filePath) /** * Retrieve an environment variable. */ -function env($key, $default = null) +function env(string $key, mixed $default = null): mixed { $v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default); return match(true) { @@ -135,14 +135,6 @@ function csrf() return $_SESSION['csrf']; } -/** - * Verify a CSRF token. - */ -function csrf_verify($token) -{ - return hash_equals($_SESSION['csrf'] ?? '', $token); -} - /** * Create a hidden input field for CSRF tokens. */ @@ -157,7 +149,7 @@ function csrf_field() function csrf_ensure() { $csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? ''; - if (!csrf_verify($csrf)) error_response(418); + if (!hash_equals($_SESSION['csrf'] ?? '', $csrf)) error_response(418); } /** @@ -181,7 +173,7 @@ function set_cookie($name, $value, $expires) function user(): User|false { if (empty($_SESSION['user'])) return false; - return unserialize($_SESSION['user']); + return $GLOBALS['state']['user'] ??= unserialize($_SESSION['user']); } /** @@ -277,35 +269,6 @@ function array_to_ul($array) return ""; } -/** - * Start a keyed stopwatch to measure the time between two points in the code. - */ -function stopwatch_start($key) -{ - if (!env('debug', false)) return; - $GLOBALS['stopwatch'][$key] = microtime(true); -} - -/** - * Stop a keyed stopwatch. Stores the time in the global $stopwatch array under the key. - */ -function stopwatch_stop($key) -{ - if (!env('debug', false)) return; - if (empty($GLOBALS['stopwatch'][$key])) return 0; - $GLOBALS['stopwatch'][$key] = microtime(true) - $GLOBALS['stopwatch'][$key]; -} - -/** - * Get the stopwatch value and format it to within 10 digits. - */ -function stopwatch_get(string $key): string -{ - if (!env('debug', false)) return ''; - if (empty($GLOBALS['stopwatch'][$key])) return ''; - return number_format($GLOBALS['stopwatch'][$key], 10); -} - /** * Conditional Echo; if the condition is true, echo the value. If the condition is false, echo the $or value. */ @@ -322,22 +285,6 @@ function is_htmx(): bool return isset($_SERVER['HTTP_HX_REQUEST']); } -/** - * Get whether the request is an AJAX (fetch) request. - */ -function is_ajax(): bool -{ - return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; -} - -/** - * Limit a request to AJAX only. - */ -function ajax_only(): void -{ - if (!is_ajax()) error_response(418); -} - /** * Return a JSON response with the given data. */ @@ -435,17 +382,6 @@ function parse_bbcode(string $text): array ]; } -/** - * Shorthand to verify auth, a character is selected, CSRF is correct, and it is an AJAX request. Used for - * front-end API routes. - */ -function ui_guard() -{ - auth_only_and_must_have_character(); - ajax_only(); - csrf_ensure(); -} - /** * Shorthand to call fetchArray() on a SQLite3Result. Defaults to SQLITE3_ASSOC but can pass any constant to $mode. */ @@ -453,3 +389,65 @@ function db_fetch_array(SQLite3Result $result, int $mode = SQLITE3_ASSOC): array { return $result->fetchArray($mode); } + +/** + * Returns whether there is a valid remember cookie, and whether that cookie has a valid session token. + */ +function validate_session(): bool +{ + if (!isset($_COOKIE['remember_me'])) return false; + if (($session = Session::find($_COOKIE['remember_me'])) && $session->validate()) return true; + return false; +} + +/** + * Ensure this is an AJAX-only request. + */ +function ajax_only(): void +{ + $header = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? ''; + if (!strtolower($header) === 'xmlhttprequest') error_response(418); +} + +/** + * Mark a request as guest only. + */ +function guest_only(): void +{ + if ($GLOBALS['state']['logged_in']) redirect('/'); +} + +/** + * Mark a request as auth'd only. + */ +function auth_only(): void +{ + if (!$GLOBALS['state']['logged_in']) redirect('/auth/login'); +} + +/** + * Mark a request as needing to have a character selected. Automatically checks auth_only as well. + */ +function must_have_character(): void +{ + auth_only(); + $user = user(); + + // If there is a character selected, make sure the session is up to date. + if ($user->char_id !== 0) { + char(); + return; + } + + // if no characters, redirect to create first + if ($user->char_count() === 0) redirect('/character/create-first'); + + // if no character selected, select the first one + if ($user->char_id === 0) { + $char = live_db()->query( + 'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1', + [':u' => $user->id] + )->fetchArray(SQLITE3_ASSOC); + change_user_character($char['id']); + } +} diff --git a/src/router.php b/src/router.php index 0c8e8be..67db739 100644 --- a/src/router.php +++ b/src/router.php @@ -16,6 +16,11 @@ class Router */ private array $routes = []; + /** + * Store the last inserted node so we can register middleware and attributes to it. + */ + private array $last_inserted_node; + /** * Add a route to the route tree. The route must be a URI path, and contain dynamic segments * using a colon prefix. (:id, :slug, etc) @@ -42,7 +47,10 @@ class Router } // Add the handler to the last node - $node[$method] = $handler; + $node[$method] = ['handler' => $handler, 'middleware' => []]; + + // Store a reference to the node so we can add middleware to it. + $this->last_inserted_node = &$node[$method]; return $this; } @@ -65,8 +73,8 @@ class Router // if the URI is just a slash, we can return the handler for the root node if ($uri === '/') { return isset($node[$method]) - ? ['code' => 200, 'handler' => $node[$method], 'params' => null] - : ['code' => 405, 'handler' => null, 'params' => null]; + ? ['code' => 200, 'handler' => $node[$method]['handler']] + : ['code' => 405]; } // We'll split up the URI into segments and traverse the node tree @@ -85,13 +93,22 @@ class Router } // if we can't find a node for this segment, return 404 - return ['code' => 404, 'handler' => null, 'params' => []]; + return ['code' => 404]; } // if we found a handler for the method, return it and any params. if not, return a 405 return isset($node[$method]) - ? ['code' => 200, 'handler' => $node[$method], 'params' => $params ?? []] - : ['code' => 405, 'handler' => null, 'params' => []]; + ? ['code' => 200, 'handler' => $node[$method]['handler'], 'params' => $params ?? [], 'middleware' => $node[$method]['middleware']] + : ['code' => 405]; + } + + /** + * Add a middleware function to the last inserted node's stack. + */ + public function middleware(callable $middleware): Router + { + $this->last_inserted_node['middleware'][] = $middleware; + return $this; } /**