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, `token` TEXT NOT NULL UNIQUE,
`expires` INTEGER NOT NULL `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; DROP TABLE IF EXISTS tokens;
CREATE TABLE tokens ( CREATE TABLE tokens (

View File

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

View File

@ -20,85 +20,14 @@ $r->get('/', function() {
/* /*
Auth Auth
*/ */
$r->get('/register', function() { $r->get('/register', 'Actions\Auth::register_get')->middleware('guest_only');
guest_only(); $r->post('/register', 'Actions\Auth::register_post')->middleware('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('/login', function() { $r->get('/login', function() {
guest_only();
echo render('layouts/basic', ['view' => 'pages/auth/login']); echo render('layouts/basic', ['view' => 'pages/auth/login']);
}); })->middleware('guest_only');
$r->post('/login', function() { $r->post('/login', function() {
guest_only();
csrf_ensure();
$errors = []; $errors = [];
$u = trim($_POST['u'] ?? ''); $u = trim($_POST['u'] ?? '');
@ -138,10 +67,9 @@ $r->post('/login', function() {
} }
redirect('/'); redirect('/');
}); })->middleware('guest_only');
$r->post('/logout', function() { $r->post('/logout', function() {
csrf_ensure();
Session::delete(user()->id); Session::delete(user()->id);
unset($_SESSION['user']); unset($_SESSION['user']);
set_cookie('remember_me', '', 1); set_cookie('remember_me', '', 1);
@ -159,18 +87,10 @@ $r->get('/debug/logout', function() {
Characters Characters
*/ */
$r->get('/characters', function() { $r->get('/characters', function() {
auth_only_and_must_have_character();
$GLOBALS['active_nav_tab'] = 'chars';
//echo page('chars/list', ['chars' => user()->char_list()]); //echo page('chars/list', ['chars' => user()->char_list()]);
}); })->middleware('must_have_character');
$r->post('/characters', function() { $r->post('/characters', function() {
auth_only_and_must_have_character();
csrf_ensure();
$GLOBALS['active_nav_tab'] = 'chars';
$char_id = (int) ($_POST['char_id'] ?? 0); $char_id = (int) ($_POST['char_id'] ?? 0);
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
@ -210,24 +130,16 @@ $r->post('/characters', function() {
} }
redirect('/characters'); redirect('/characters');
}); })->middleware('must_have_character');
$r->get('/character/create-first', function() { $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 the user already has a character, redirect them to the main page.
if (user()->char_count() > 0) redirect('/'); if (user()->char_count() > 0) redirect('/');
//echo page('chars/first'); //echo page('chars/first');
}); })->middleware('auth_only');
$r->post('/character/create', function() { $r->post('/character/create', function() {
auth_only(); csrf_ensure();
$GLOBALS['active_nav_tab'] = 'chars';
$errors = []; $errors = [];
$name = trim($_POST['n'] ?? ''); $name = trim($_POST['n'] ?? '');
@ -275,12 +187,9 @@ $r->post('/character/create', function() {
flash('alert_character_list_1', ['success', 'Character <b>' . $name . '</b> created!']); flash('alert_character_list_1', ['success', 'Character <b>' . $name . '</b> created!']);
redirect('/characters'); redirect('/characters');
}); })->middleware('auth_only');
$r->post('/character/delete', function() { $r->post('/character/delete', function() {
auth_only_and_must_have_character();
csrf_ensure();
$char_id = (int) ($_POST['char_id'] ?? 0); $char_id = (int) ($_POST['char_id'] ?? 0);
// If the character ID is not a number, return a 400. // 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.']); flash('alert_character_list_1', ['danger', 'Character <b>' . $char['name'] . '</b> deleted.']);
redirect('/characters'); redirect('/characters');
}); })->middleware('must_have_character');
/* /*
World World
*/ */
$r->get('/world', function() { $r->get('/world', function() {
auth_only_and_must_have_character();
echo render('layouts/game'); echo render('layouts/game');
}); })->middleware('must_have_character');
$r->post('/move', function() { $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. 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', [ define('directions', [
[0, -1], // Up [0, -1], // Up
[0, 1], // Down [0, 1], // Down
@ -361,28 +267,24 @@ $r->post('/move', function() {
if ($r === false) throw new Exception('Failed to move character. (wcmp)'); if ($r === false) throw new Exception('Failed to move character. (wcmp)');
json_response(['x' => $x, 'y' => $y]); json_response(['x' => $x, 'y' => $y]);
}); })->middleware('ajax_only')->middleware('must_have_character');
/* /*
UI UI
*/ */
$r->get('/ui/stats', function() { $r->get('/ui/stats', function() {
ui_guard();
echo c_profile_stats(char()); echo c_profile_stats(char());
}); })->middleware('ajax_only')->middleware('must_have_character');
/* /*
Router Router
*/ */
// [code, handler, params] // [code, handler, params, middleware]
stopwatch_start('router');
$l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); $l = $r->lookup($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
stopwatch_stop('router');
stopwatch_start('handler');
if ($l['code'] !== 200) error_response($l['code']); if ($l['code'] !== 200) error_response($l['code']);
if (!empty($l['middleware'])) foreach ($l['middleware'] as $middleware) $middleware();
$l['handler'](...$l['params'] ?? []); $l['handler'](...$l['params'] ?? []);
stopwatch_stop('handler');
/* /*
Cleanup 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,24 +1,37 @@
<?php <?php
session_start();
// SRC is defined as the path to the src/ directory from public/ // SRC is defined as the path to the src/ directory from public/
define('CLASS_MAP', [ /*
'Database' => '/database.php', ============================================================
'Router' => '/router.php', Libraries
'User' => '/models/user.php', ============================================================
'Character' => '/models/character.php', */
'Wallet' => '/models/wallet.php',
'Session' => '/models/session.php',
]);
require_once SRC . '/helpers.php'; require_once SRC . '/helpers.php';
require_once SRC . 'auth.php'; require_once SRC . '/components.php';
require_once SRC . 'components.php'; require_once SRC . '/enums.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) { spl_autoload_register(function (string $class) {
if (array_key_exists($class, CLASS_MAP)) require_once SRC . CLASS_MAP[$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)); define('START_TIME', microtime(true));
/* /*
Load env, set error reporting, etc. ============================================================
.ENV
============================================================
*/ */
env_load(SRC . '/../.env'); env_load(SRC . '/../.env');
if (env('debug') === 'true') { if (env('debug', false)) {
ini_set('display_errors', '1'); ini_set('display_errors', '1');
ini_set('display_startup_errors', '1'); ini_set('display_startup_errors', '1');
error_reporting(E_ALL); 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) // error any request that fails CSRF on these methods
csrf(); 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,81 +1,78 @@
<?php <?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 int $count = 0; public array $log = [];
public array $log = []; public float $query_time = 0;
public float $query_time = 0;
public function __construct(string $db_path) public function __construct(string $db_path)
{ {
$db = new SQLite3($db_path); parent::__construct($db_path);
// Increase cache size to 32MB parent::exec('PRAGMA cache_size = 32000');
$db->exec('PRAGMA cache_size = 32000'); parent::exec('PRAGMA journal_mode = WAL');
// Enable WAL mode parent::exec('PRAGMA temp_store = MEMORY');
$db->exec('PRAGMA journal_mode = WAL'); }
// Move temp store to memory
$db->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 if (!empty($params)) {
{ foreach ($params as $k => $v) {
$p = strpos($query, '?') !== false; // generic placeholders? $stmt->bindValue($p ? $k + 1 : $k, $v, $this->getSQLiteType($v));
$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;
}
public function exec(string $query): bool $start = microtime(true);
{ $r = $stmt->execute();
$start = microtime(true); $this->log($query, microtime(true) - $start);
$r = $this->db->exec($query);
$this->log($query, microtime(true) - $start);
return $r;
}
public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool return $r;
{ }
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";
}
$result = $this->query($query, [':v' => $value]); public function exec(string $query): bool
return $result->fetchArray(SQLITE3_NUM) !== false; {
} $start = microtime(true);
$r = parent::exec($query);
$this->log($query, microtime(true) - $start);
return $r;
}
private function log(string $query, float $time_taken): void public function exists(string $table, string $column, mixed $value, bool $case_insensitive = true): bool
{ {
$this->count++; if ($case_insensitive) {
$this->query_time += $time_taken; $query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1";
if (env('debug', false)) $this->log[] = [$query, $time_taken]; } else {
} $query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1";
}
private function getSQLiteType(mixed $value): int $result = $this->query($query, [':v' => $value]);
{ return $result->fetchArray(SQLITE3_NUM) !== false;
return match (true) { }
is_int($value) => SQLITE3_INTEGER,
is_float($value) => SQLITE3_FLOAT,
is_null($value) => SQLITE3_NULL,
default => SQLITE3_TEXT
};
}
public function lastInsertRowID(): int private function log(string $query, float $time_taken): void
{ {
return $this->db->lastInsertRowID(); $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
};
}
} }

View File

@ -3,7 +3,7 @@
/** /**
* Load the environment variables from the .env file. * 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)"); 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. * 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); $v = $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default);
return match(true) { return match(true) {
@ -135,14 +135,6 @@ function csrf()
return $_SESSION['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. * Create a hidden input field for CSRF tokens.
*/ */
@ -157,7 +149,7 @@ function csrf_field()
function csrf_ensure() function csrf_ensure()
{ {
$csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? ''; $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 function user(): User|false
{ {
if (empty($_SESSION['user'])) return 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>"; 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. * 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']); 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. * 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. * 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); 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 = []; 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 * 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) * using a colon prefix. (:id, :slug, etc)
@ -42,7 +47,10 @@ class Router
} }
// Add the handler to the last node // 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; 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 the URI is just a slash, we can return the handler for the root node
if ($uri === '/') { if ($uri === '/') {
return isset($node[$method]) return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => null] ? ['code' => 200, 'handler' => $node[$method]['handler']]
: ['code' => 405, 'handler' => null, 'params' => null]; : ['code' => 405];
} }
// We'll split up the URI into segments and traverse the node tree // 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 // 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 // if we found a handler for the method, return it and any params. if not, return a 405
return isset($node[$method]) return isset($node[$method])
? ['code' => 200, 'handler' => $node[$method], 'params' => $params ?? []] ? ['code' => 200, 'handler' => $node[$method]['handler'], 'params' => $params ?? [], 'middleware' => $node[$method]['middleware']]
: ['code' => 405, 'handler' => null, 'params' => []]; : ['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;
} }
/** /**