Significant refactors, add middleware to router

This commit is contained in:
Sky Johnson 2024-12-03 21:00:07 -06:00
parent d82e0fdaf3
commit 50b78f8131
9 changed files with 297 additions and 348 deletions

View File

@ -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 (

View File

@ -72,6 +72,6 @@ case $1 in
drop_db
;;
*)
echo "Usage: $0 {create|populate|reset}"
echo "Usage: $0 {create|populate|reset|drop}"
;;
esac

View File

@ -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 <b>' . $name . '</b> 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 <b>' . $char['name'] . '</b> 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

76
src/actions/auth.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace Actions;
use \User;
class Auth
{
public static function register_get(): void
{
echo render('layouts/basic', ['view' => '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');
}
}

View File

@ -1,72 +0,0 @@
<?php
/**
* Check for a user session. If $_SESSION['user'] already exists, return early. If not, check for a remember me
* cookie. If a remember me cookie exists, validate the session and set $_SESSION['user'].
*/
function auth_check()
{
if (isset($_SESSION['user'])) return true;
if (isset($_COOKIE['remember_me'])) {
$session = Session::find($_COOKIE['remember_me']);
if ($session->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();
}

View File

@ -1,9 +1,28 @@
<?php
session_start();
// SRC is defined as the path to the src/ directory from public/
/*
============================================================
Libraries
============================================================
*/
require_once SRC . '/helpers.php';
require_once SRC . '/components.php';
require_once SRC . '/enums.php';
/*
============================================================
PHP Sessions
============================================================
*/
session_start();
/*
============================================================
Autoloading
============================================================
*/
define('CLASS_MAP', [
'Database' => '/database.php',
'Router' => '/router.php',
@ -11,15 +30,9 @@ define('CLASS_MAP', [
'Character' => '/models/character.php',
'Wallet' => '/models/wallet.php',
'Session' => '/models/session.php',
'Actions\Auth' => '/actions/auth.php',
]);
require_once SRC . '/helpers.php';
require_once SRC . 'auth.php';
require_once SRC . 'components.php';
require_once SRC . 'enums.php';
require_once SRC . '/models/token.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()
];

View File

@ -1,46 +1,45 @@
<?php
/**
* Generic wrapper around a SQLite3 object to add our own semantics to data operations.
* An extension to the SQLite3 class to add our own logging and binding semantics!
*/
class Database
class Database extends SQLite3
{
private SQLite3 $db;
public int $count = 0;
public array $log = [];
public float $query_time = 0;
public function __construct(string $db_path)
{
$db = new SQLite3($db_path);
parent::__construct($db_path);
// Increase cache size to 32MB
$db->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');
$this->db = $db;
parent::exec('PRAGMA cache_size = 32000');
parent::exec('PRAGMA journal_mode = WAL');
parent::exec('PRAGMA temp_store = MEMORY');
}
public function query(string $query, array $params = []): SQLite3Result|false
{
$p = strpos($query, '?') !== false; // generic placeholders?
$stmt = $this->db->prepare($query);
$p = strpos($query, '?') !== false;
$stmt = $this->prepare($query);
if (!empty($params)) {
foreach ($params as $k => $v) $stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v));
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;
}
public function exec(string $query): bool
{
$start = microtime(true);
$r = $this->db->exec($query);
$r = parent::exec($query);
$this->log($query, microtime(true) - $start);
return $r;
}
@ -61,7 +60,10 @@ class Database
{
$this->count++;
$this->query_time += $time_taken;
if (env('debug', false)) $this->log[] = [$query, $time_taken];
if (env('debug', false)) {
$this->log[] = [$query, $time_taken];
}
}
private function getSQLiteType(mixed $value): int
@ -73,9 +75,4 @@ class Database
default => SQLITE3_TEXT
};
}
public function lastInsertRowID(): int
{
return $this->db->lastInsertRowID();
}
}

View File

@ -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 "<ul>$html</ul>";
}
/**
* 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']);
}
}

View File

@ -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;
}
/**