diff --git a/public/assets/css/forms.css b/public/assets/css/forms.css index 23d733d..7f10d71 100644 --- a/public/assets/css/forms.css +++ b/public/assets/css/forms.css @@ -126,3 +126,16 @@ .character-select:not(:has(input[type="radio"]:checked)) > .buttons { display: none; } + +form.logout-form { + display: inline-block; + + & > button { + display: inline-block; + appearance: none; + background: none; + outline: none; + border: none; + cursor: pointer; + } +} diff --git a/public/assets/css/game.css b/public/assets/css/game.css index 9266295..b530515 100644 --- a/public/assets/css/game.css +++ b/public/assets/css/game.css @@ -1,15 +1,6 @@ -@import 'src/buttons.css'; - -:root { - font-size: 16px; - --main-font: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif; -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +@import 'utilities.css'; +@import 'buttons.css'; +@import 'forms.css'; body { background-color: #bcc6cf; @@ -18,16 +9,13 @@ body { background-position: center top; background-repeat: no-repeat; font-family: var(--main-font); -} - -main#game-container { width: 100vw; height: 100vh; overflow: hidden; position: relative; } -div#game-ui, div#game-windows { +div#ui, div#windows { position: absolute; top: 0; left: 0; @@ -37,7 +25,18 @@ div#game-ui, div#game-windows { height: 100%; } -div#game-windows { +div#ui { + section#menu { + display: flex; + gap: 0.5rem; + + & > a { + cursor: pointer; + } + } +} + +div#windows { display: flex; align-items: center; justify-content: center; @@ -99,7 +98,7 @@ div#game-windows { } } -canvas#game-canvas { +canvas#canvas { width: 100%; height: 100%; position: absolute; diff --git a/public/assets/img/icons/earth.png b/public/assets/img/icons/earth.png deleted file mode 100644 index ac09f83..0000000 Binary files a/public/assets/img/icons/earth.png and /dev/null differ diff --git a/public/assets/img/icons/home.png b/public/assets/img/icons/home.png deleted file mode 100644 index 757d5c1..0000000 Binary files a/public/assets/img/icons/home.png and /dev/null differ diff --git a/public/assets/img/icons/map.png b/public/assets/img/icons/map.png deleted file mode 100644 index 36ba484..0000000 Binary files a/public/assets/img/icons/map.png and /dev/null differ diff --git a/public/assets/img/icons/settings.png b/public/assets/img/icons/settings.png deleted file mode 100644 index 83ce6c1..0000000 Binary files a/public/assets/img/icons/settings.png and /dev/null differ diff --git a/public/assets/img/icons/shop.png b/public/assets/img/icons/shop.png deleted file mode 100644 index 865433e..0000000 Binary files a/public/assets/img/icons/shop.png and /dev/null differ diff --git a/public/assets/img/icons/user1.png b/public/assets/img/icons/user1.png deleted file mode 100644 index 6842e40..0000000 Binary files a/public/assets/img/icons/user1.png and /dev/null differ diff --git a/public/assets/img/icons/world.png b/public/assets/img/icons/world.png deleted file mode 100644 index b1e9096..0000000 Binary files a/public/assets/img/icons/world.png and /dev/null differ diff --git a/public/index.php b/public/index.php index 56cc1ca..3924c3c 100644 --- a/public/index.php +++ b/public/index.php @@ -4,6 +4,7 @@ Setup */ define('SRC', __DIR__ . '/../src'); +define('DATABASE_PATH', __DIR__ . '/../database'); require_once SRC . '/bootstrap.php'; $r = new Router; @@ -12,8 +13,8 @@ $r = new Router; Home */ $r->get('/', function() { - if (!user()) redirect('/login'); - redirect('/world'); + if (user()) redirect('/world'); + echo render('layouts/basic', ['view' => 'pages/hello']); }); /* @@ -78,7 +79,7 @@ $r->post('/register', function() { // If there are errors at this point, send them to the page with errors flashed. if (!empty($errors)) { $GLOBALS['form-errors'] = $errors; - echo page('auth/register'); + echo render('layouts/basic', ['view' => 'pages/auth/register']); exit; } @@ -124,20 +125,14 @@ $r->post('/login', function() { $_SESSION['user'] = serialize($user); if ($_POST['remember'] ?? false) { - $token = token(); - $expires = strtotime('+30 days'); - $result = db_query( - db_auth(), - "INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", - [':t' => $token, ':u' => user()->id, ':e' => $expires] - ); - if (!$result) error_response(400); - set_cookie('remember_me', $token, $expires); + $session = Session::create($user->id, strtotime('+30 days')); + if ($session === false) error_response(400); + set_cookie('remember_me', $session->token, $session->expires); } - if (user()->char_count() === 0) { + if ($user->char_count() === 0) { redirect('/character/create-first'); - } elseif (!change_user_character(user()->char_id)) { + } elseif (!change_user_character($user->char_id)) { echo "failed to change user character (aclp)"; error_response(999); } @@ -147,14 +142,14 @@ $r->post('/login', function() { $r->post('/logout', function() { csrf_ensure(); - session_delete(user()->id); + Session::delete(user()->id); unset($_SESSION['user']); set_cookie('remember_me', '', 1); redirect('/'); }); $r->get('/debug/logout', function() { - session_delete(user()->id); + Session::delete(user()->id); unset($_SESSION['user']); set_cookie('remember_me', '', 1); redirect('/'); @@ -167,7 +162,7 @@ $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()]); }); $r->post('/characters', function() { @@ -210,7 +205,7 @@ $r->post('/characters', function() { if ($action === 'delete') { if (!Character::belongs_to($char_id, user()->id)) error_response(999); - echo page('chars/delete', ['char' => Character::find($char_id)]); + //echo page('chars/delete', ['char' => Character::find($char_id)]); exit; } @@ -225,7 +220,7 @@ $r->get('/character/create-first', function() { // If the user already has a character, redirect them to the main page. if (user()->char_count() > 0) redirect('/'); - echo page('chars/first'); + //echo page('chars/first'); }); $r->post('/character/create', function() { @@ -257,11 +252,11 @@ $r->post('/character/create', function() { if (isset($_POST['first']) && $_POST['first'] === 'true') { // If this is the first character, return to the first character creation page. - echo page('chars/first'); + //echo page('chars/first'); exit; } else { // If this is not the first character, return to the character list page. - echo page('chars/list', ['chars' => user()->char_list()]); + //echo page('chars/list', ['chars' => user()->char_list()]); exit; } } @@ -357,7 +352,7 @@ $r->post('/move', function() { error_response(999); } - $r = db_query(db_live(), 'UPDATE char_locations SET x = :x, y = :y WHERE char_id = :c', [ + $r = live_db()->query('UPDATE char_locations SET x = :x, y = :y WHERE char_id = :c', [ ':x' => $x, ':y' => $y, ':c' => user()->char_id @@ -371,7 +366,7 @@ $r->post('/move', function() { /* UI */ -$r->post('/ui/stats', function() { +$r->get('/ui/stats', function() { ui_guard(); echo c_profile_stats(char()); }); diff --git a/src/bootstrap.php b/src/bootstrap.php index 70bd483..e6c1ed2 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -5,24 +5,19 @@ session_start(); // SRC is defined as the path to the src/ directory from public/ define('CLASS_MAP', [ - 'User' => '/models/user.php', + 'Database' => '/database.php', + 'Router' => '/router.php', + 'User' => '/models/user.php', 'Character' => '/models/character.php', - 'Wallet' => '/models/wallet.php' + 'Wallet' => '/models/wallet.php', + 'Session' => '/models/session.php', ]); require_once SRC . '/helpers.php'; - -stopwatch_start('bootstrap'); // Start the bootstrap stopwatch - -require_once SRC . '/util/env.php'; -require_once SRC . '/util/database.php'; require_once SRC . '/util/auth.php'; -require_once SRC . '/util/router.php'; require_once SRC . '/util/components.php'; -require_once SRC . '/util/render.php'; require_once SRC . '/util/enums.php'; -require_once SRC . '/models/session.php'; require_once SRC . '/models/token.php'; spl_autoload_register(function (string $class) { @@ -43,14 +38,11 @@ if (env('debug') === 'true') { error_reporting(E_ALL); } +// Create an array in GLOBALS to hold database connections. +$GLOBALS['databases'] = []; + // Generate a new CSRF token. (if one doesn't exist, that is) csrf(); -// Have global counters for queries -$GLOBALS['queries'] = 0; -$GLOBALS['query_time'] = 0; - // Run auth_check to see if we're logged in, since it populates the user data in SESSION auth_check(); - -stopwatch_stop('bootstrap'); // Stop the bootstrap stopwatch diff --git a/src/database.php b/src/database.php new file mode 100644 index 0000000..05078de --- /dev/null +++ b/src/database.php @@ -0,0 +1,81 @@ +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; + } + + 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; + } + + public function exec(string $query): bool + { + $start = microtime(true); + $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 + { + 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]); + return $result->fetchArray(SQLITE3_NUM) !== false; + } + + 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 + }; + } + + public function lastInsertRowID(): int + { + return $this->db->lastInsertRowID(); + } +} diff --git a/src/helpers.php b/src/helpers.php index 1be12eb..69e6332 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,5 +1,86 @@ true, + $v === 'false' => false, + is_numeric($v) => (int) $v, + is_float($v) => (float) $v, + default => $v + }; +} + +/** + * Return the path to a view file. + */ +function template(string $name): string +{ + return SRC . "/../templates/$name.php"; +} + +/** + * Render a view with the given data. Looks for `$view` through `template()`. + */ +function render(string $path_to_base_view, array $data = []): string|false +{ + ob_start(); + extract($data); + require template($path_to_base_view); + return ob_get_clean(); +} + +/** + * Get the auth database connection from GLOBALS['databases'], or create it if it doesn't exist. + */ +function auth_db(): Database +{ + return $GLOBALS['databases']['auth'] ??= new Database(DATABASE_PATH . '/auth.db'); +} + +/** + * Get the live database connection from GLOBALS['databases'], or create it if it doesn't exist. + */ +function live_db(): Database +{ + return $GLOBALS['databases']['live'] ??= new Database(DATABASE_PATH . '/live.db'); +} + /** * Generate a pretty dope token. */ @@ -75,7 +156,8 @@ function csrf_field() */ function csrf_ensure() { - if (!csrf_verify($_POST['csrf'] ?? '')) error_response(418); + $csrf = $_POST['csrf'] ?? $_SERVER['HTTP_X_CSRF'] ?? ''; + if (!csrf_verify($csrf)) error_response(418); } /** @@ -138,7 +220,7 @@ function change_user_character(int $char_id): bool // If the character ID is different, update the session and database if (user()->char_id !== $char_id) { modify_user_session('char_id', $char_id); - db_query(db_auth(), "UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]); + auth_db()->query("UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user()->id]); } return true; @@ -175,8 +257,7 @@ function wallet(): Wallet|false function location($field = '') { if (empty($GLOBALS['location'])) { - $GLOBALS['location'] = db_query( - db_live(), + $GLOBALS['location'] = live_db()->query( "SELECT * FROM char_locations WHERE char_id = :c", [':c' => user()->char_id] )->fetchArray(SQLITE3_ASSOC); @@ -289,7 +370,7 @@ function error_response(int $code): void */ function title(int $title_id): array|false { - return db_query(db_live(), 'SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray(); + return live_db()->query('SELECT * FROM titles WHERE id = ?', [$title_id])->fetchArray(); } /** @@ -364,3 +445,11 @@ function ui_guard() ajax_only(); csrf_ensure(); } + +/** + * Shorthand to call fetchArray() on a SQLite3Result. Defaults to SQLITE3_ASSOC but can pass any constant to $mode. + */ +function db_fetch_array(SQLite3Result $result, int $mode = SQLITE3_ASSOC): array|false +{ + return $result->fetchArray($mode); +} diff --git a/src/models/character.php b/src/models/character.php index ae4fc63..3bfbaf1 100644 --- a/src/models/character.php +++ b/src/models/character.php @@ -70,8 +70,7 @@ class Character */ public static function find(int|string $id): Character|false { - $q = db_query( - db_live(), + $q = live_db()->query( "SELECT * FROM characters WHERE id = :id OR name = :id COLLATE NOCASE", [':id' => $id] ); @@ -98,13 +97,13 @@ class Character $v = implode(', ', array_map(fn($x) => ":$x", $k)); // Create the character! - if (db_query(db_live(), "INSERT INTO characters ($f) VALUES ($v)", $data) === false) { + if (live_db()->query("INSERT INTO characters ($f) VALUES ($v)", $data) === false) { // @TODO: Log this error throw new Exception('Failed to create character. (cc)'); } // Get the character ID - return Character::find(db_live()->lastInsertRowID()); + return Character::find(live_db()->lastInsertRowID()); } /** @@ -112,8 +111,7 @@ class Character */ public function create_location(int $x = 0, int $y = 0, int $currently = 0): bool { - $l = db_query( - db_live(), + $l = live_db()->query( "INSERT INTO char_locations (char_id, x, y, currently) VALUES (:i, :x, :y, :c)", [':i' => $this->id, ':x' => $x, ':y' => $y, ':c' => $currently] ); @@ -126,7 +124,7 @@ class Character public function create_gear(array $initialGear = []): bool { // @TODO implement initial gear - $g = db_query(db_live(), "INSERT INTO char_gear (char_id) VALUES (:i)", [':i' => $this->id]); + $g = live_db()->query("INSERT INTO char_gear (char_id) VALUES (:i)", [':i' => $this->id]); return $g !== false; } @@ -135,7 +133,7 @@ class Character */ public static function name_exists(string $name): bool { - return db_exists(db_live(), 'characters', 'name', $name); + return live_db()->exists('characters', 'name', $name); } /** @@ -143,7 +141,7 @@ class Character */ public static function exists(int $id): bool { - return db_exists(db_live(), 'characters', 'id', $id); + return live_db()->exists('characters', 'id', $id); } /** @@ -151,8 +149,7 @@ class Character */ public static function belongs_to(int $id, int $user_id): bool { - $q = db_query( - db_live(), + $q = live_db()->query( "SELECT 1 FROM characters WHERE id = :i AND user_id = :u LIMIT 1", [':i' => $id, ':u' => $user_id] ); @@ -175,8 +172,7 @@ class Character */ public function award_title(int $title_id): bool { - $r = db_query( - db_live(), + $r = live_db()->query( 'INSERT INTO owned_titles (`title_id`, `char_id`) VALUES (:t, :i)', [':t' => $title_id, ':i' => $this->id] ); @@ -189,84 +185,9 @@ class Character public static function delete(int $id) { // Delete the character - if (db_query(db_live(), "DELETE FROM characters WHERE id = :p", [':p' => $id]) === false) { + if (live_db()->query("DELETE FROM characters WHERE id = :p", [':p' => $id]) === false) { throw new Exception('Failed to delete character. (C::d)'); } - - // Get item IDs from the character's inventory - $items = db_query(db_live(), "SELECT item_id FROM char_inventory WHERE char_id = :p", [':p' => $id]); - // delete the character's inventory and items - while ($row = $items->fetchArray(SQLITE3_ASSOC)) { - if (db_query(db_live(), "DELETE FROM char_inventory WHERE char_id = :c", [':c' => $id]) === false) { - throw new Exception('Failed to delete character inventory. (C::d)'); - } - - if (db_query(db_live(), "DELETE FROM items WHERE id = :p", [':p' => $row['id']]) === false) { - throw new Exception('Failed to delete character item slots. (C::d)'); - } - } - - // Delete the character's location - if (db_query(db_live(), "DELETE FROM char_locations WHERE char_id = :p", [':p' => $id]) === false) { - throw new Exception('Failed to delete character location. (C::d)'); - } - - // Delete the character's gear - if (db_query(db_live(), "DELETE FROM char_gear WHERE char_id = :p", [':p' => $id]) === false) { - throw new Exception('Failed to delete character gear. (C::d)'); - } - - // Delete the character's bank - if (db_query(db_live(), "DELETE FROM char_bank WHERE char_id = :p", [':p' => $id]) === false) { - throw new Exception('Failed to delete character bank. (C::d)'); - } - - // Delete character's banked items - if (db_query(db_live(), "DELETE FROM char_banked_items WHERE char_id = :p", [':p' => $id]) === false) { - throw new Exception('Failed to delete character bank items. (C::d)'); - } - - // Delete the user's guild membership - if (db_query(db_live(), "DELETE FROM guild_members WHERE char_id = :p", [':p' => $id]) === false) { - throw new Exception('Failed to delete character guild membership. (C::d)'); - } - - // if the character was a guild leader, hand leadership to the next highest ranking member - $guild = db_query(db_live(), "SELECT id FROM guilds WHERE leader_id = :p", [':p' => $id])->fetchArray(SQLITE3_ASSOC); - if ($guild !== false) { - $members = db_query(db_live(), "SELECT char_id FROM guild_members WHERE guild_id = :p ORDER BY rank DESC", [':p' => $guild['id']]); - $newLeader = $members->fetchArray(SQLITE3_ASSOC); - if ($newLeader !== false) { - db_query(db_live(), "UPDATE guilds SET leader_id = :p WHERE id = :g", [':p' => $newLeader['char_id'], ':g' => $guild['id']]); - } - } - - // Get a list of all pve fight IDs. - $pve = db_query(db_fights(), "SELECT id FROM pve WHERE char_id = :p", [':p' => $id]); - // Get a list of all pvp fight IDs. - $pvp = db_query(db_fights(), "SELECT id FROM pvp WHERE char1_id = :p OR char2_id = :p", [':p' => $id]); - - // Delete all pve fights - while ($row = $pve->fetchArray(SQLITE3_ASSOC)) { - if (db_query(db_fights(), "DELETE FROM pve WHERE id = :p", [':p' => $row['id']]) === false) { - throw new Exception('Failed to delete pve fight. (C::d)'); - } - - if (db_query(db_fights(), "DELETE FROM pve_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) { - throw new Exception('Failed to delete pve fight logs. (C::d)'); - } - } - - // Delete all pvp fights - while ($row = $pvp->fetchArray(SQLITE3_ASSOC)) { - if (db_query(db_fights(), "DELETE FROM pvp WHERE id = :p", [':p' => $row['id']]) === false) { - throw new Exception('Failed to delete pvp fight. (C::d)'); - } - - if (db_query(db_fights(), "DELETE FROM pvp_logs WHERE fight_id = :p", [':p' => $row['id']]) === false) { - throw new Exception('Failed to delete pvp fight logs. (C::d)'); - } - } } /** @@ -278,8 +199,7 @@ class Character $t = title($this->title_id); - $q = db_query( - db_live(), + $q = live_db()->query( 'SELECT awarded FROM owned_titles WHERE char_id = ? AND title_id = ? LIMIT 1', [$this->id, $this->title_id] ); diff --git a/src/models/session.php b/src/models/session.php index 687e3a6..e7a55b5 100644 --- a/src/models/session.php +++ b/src/models/session.php @@ -1,50 +1,46 @@ $token, - ':u' => $userId, - ':e' => $expires - ]); - if (!$result) return false; - return $token; -} + public function __construct( + public int $user_id, + public string $token, + public int $expires + ) {} -/** - * Find a session by token. - */ -function session_find($token) -{ - $result = db_query(db_auth(), "SELECT * FROM sessions WHERE token = :t", [':t' => $token]); - $session = $result->fetchArray(SQLITE3_ASSOC); - if (!$session) return false; - $result->finalize(); - return $session; -} - -/** - * Delete sessions by user id. - */ -function session_delete($userId) -{ - return db_query(db_auth(), "DELETE FROM sessions WHERE user_id = :u", [':u' => $userId]); -} - -/** - * Validate a session by token and expiration date. If expired, the session is deleted and false is returned. - */ -function session_validate($token) -{ - $session = session_find($token); - if (!$session) return false; - if ($session['expires'] < time()) { - session_delete($session['user_id']); - return false; + public static function create(int $user_id, int $expires): Session|false + { + $token = token(); + $result = auth_db()->query("INSERT INTO sessions (token, user_id, expires) VALUES (:t, :u, :e)", [ + ':t' => $token, + ':u' => $user_id, + ':e' => $expires + ]); + if ($result === false) return false; + return new Session($user_id, $token, $expires); + } + + public static function find(string $token): Session|false + { + $result = auth_db()->query("SELECT * FROM sessions WHERE token = :t", [':t' => $token]); + $session = db_fetch_array($result); + if ($session === false) return false; + $result->finalize(); + return new Session($session['user_id'], $session['token'], $session['expires']); + } + + public static function delete(int $user_id): SQLite3Result|false + { + return auth_db()->query("DELETE FROM sessions WHERE user_id = :u", [':u' => $user_id]); + } + + public function validate(): bool + { + if (empty($this->user_id) || empty($this->token)) return false; + if ($this->expires < time()) { + self::delete($this->user_id); + return false; + } + return true; } - return true; } diff --git a/src/models/token.php b/src/models/token.php index 403bd50..c5cfb37 100644 --- a/src/models/token.php +++ b/src/models/token.php @@ -6,7 +6,7 @@ function token_create($userId) { $token = token(); - $result = db_query(db_auth(), "INSERT INTO tokens (token, user_id) VALUES (:t, :u)", [ + $result = auth_db()->query("INSERT INTO tokens (token, user_id) VALUES (:t, :u)", [ ':t' => $token, ':u' => $userId ]); @@ -19,7 +19,7 @@ function token_create($userId) */ function token_find($token) { - $result = db_query(db_auth(), "SELECT * FROM tokens WHERE token = :t", [':t' => $token]); + $result = auth_db()->query("SELECT * FROM tokens WHERE token = :t", [':t' => $token]); $token = $result->fetchArray(SQLITE3_ASSOC); if (!$token) return false; $result->finalize(); @@ -31,7 +31,7 @@ function token_find($token) */ function token_delete($token) { - return db_query(db_auth(), "DELETE FROM tokens WHERE token = :t", [':t' => $token]); + return auth_db()->query("DELETE FROM tokens WHERE token = :t", [':t' => $token]); } /** diff --git a/src/models/user.php b/src/models/user.php index b660cc7..7e45bc1 100644 --- a/src/models/user.php +++ b/src/models/user.php @@ -68,8 +68,7 @@ class User */ public static function find(string|int $identifier): User|false { - $r = db_query( - db_auth(), + $r = auth_db()->query( "SELECT * FROM users WHERE username = :i COLLATE NOCASE OR email = :i COLLATE NOCASE OR id = :i LIMIT 1", [':i' => $identifier] ); @@ -86,7 +85,7 @@ class User */ public static function create(string $username, string $email, string $password, int $auth = 0): SQLite3Result|false { - return db_query(db_auth(), "INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [ + return auth_db()->query("INSERT INTO users (username, email, password, auth) VALUES (:u, :e, :p, :a)", [ ':u' => $username, ':e' => $email, ':p' => password_hash($password, PASSWORD_ARGON2ID), @@ -107,7 +106,7 @@ class User */ public static function username_exists(string $username): bool { - return db_exists(db_auth(), 'users', 'username', $username); + return auth_db()->exists('users', 'username', $username); } /** @@ -115,7 +114,7 @@ class User */ public static function email_exists(string $email): bool { - return db_exists(db_auth(), 'users', 'email', $email); + return auth_db()->exists('users', 'email', $email); } /** @@ -123,8 +122,7 @@ class User */ public static function delete(string|int $identifier): SQLite3Result|false { - return db_query( - db_auth(), + return auth_db()->query( "DELETE FROM users WHERE username = :i OR email = :i OR id = :i", [':i' => $identifier] ); @@ -135,8 +133,7 @@ class User */ public function char_count(): int { - $c = db_query( - db_live(), + $c = live_db()->query( "SELECT COUNT(*) FROM characters WHERE user_id = :u", [':u' => $this->id] )->fetchArray(SQLITE3_NUM); @@ -150,7 +147,7 @@ class User */ public function char_list(): array|false { - $q = db_query(db_live(), "SELECT id, name, level FROM characters WHERE user_id = ?", [$this->id]); + $q = live_db()->query("SELECT id, name, level FROM characters WHERE user_id = ?", [$this->id]); if ($q === false) throw new Exception('Failed to list characters. (U->cl)'); $c = []; diff --git a/src/models/wallet.php b/src/models/wallet.php index b501ce2..5609f5e 100644 --- a/src/models/wallet.php +++ b/src/models/wallet.php @@ -10,7 +10,7 @@ class Wallet public static function find(int $user_id): Wallet|false { - $r = db_query(db_live(), 'SELECT * FROM wallets WHERE user_id = ?', [$user_id]); + $r = live_db()->query('SELECT * FROM wallets WHERE user_id = ?', [$user_id]); if ($r === false) throw new Exception('Failed to query wallet. (W::f)'); // badly formed query $w = $r->fetchArray(SQLITE3_ASSOC); if ($w === false) return false; // no wallet found @@ -19,8 +19,7 @@ class Wallet public static function create(int $user_id, int $silver = -1, int $starGems = -1): SQLite3Result|false { - return db_query( - db_live(), + return live_db()->query( "INSERT INTO wallets (user_id, silver, stargem) VALUES (:u, :s, :sg)", [ ':u' => $user_id, @@ -37,7 +36,7 @@ class Wallet { $cs = $c->string(true); $new = $this->{$cs} + $amt; - return db_query(db_live(), "UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new, $this->user_id]); + return live_db()->query("UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new, $this->user_id]); } /** @@ -47,6 +46,6 @@ class Wallet { $cs = $c->string(true); $new = $this->{$cs} - $amt; - return db_query(db_live(), "UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new < 0 ? 0 : $new, $this->user_id]); + return live_db()->query("UPDATE wallets SET $cs = ? WHERE user_id = ?", [$new < 0 ? 0 : $new, $this->user_id]); } } diff --git a/src/util/router.php b/src/router.php similarity index 65% rename from src/util/router.php rename to src/router.php index d77bea7..0c8e8be 100644 --- a/src/util/router.php +++ b/src/router.php @@ -6,6 +6,14 @@ */ class Router { + /** + * List of valid HTTP verbs. + */ + private const VALID_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; + + /** + * The tree of currently registered routes. + */ private array $routes = []; /** @@ -17,6 +25,9 @@ class Router */ public function add(string $method, string $route, callable $handler): Router { + $this->validateMethod($method); + $this->validateRoute($route); + // Expand the route into segments and make dynamic segments into a common placeholder $segments = array_map(function($segment) { return str_starts_with($segment, ':') ? ':x' : $segment; @@ -98,4 +109,58 @@ class Router { return $this->add('POST', $route, $handler); } + + /** + * Shorthand to register a PUT route. + */ + public function put(string $route, callable $handler): Router + { + return $this->add('PUT', $route, $handler); + } + + /** + * Shorthand to register a DELETE route. + */ + public function delete(string $route, callable $handler): Router + { + return $this->add('DELETE', $route, $handler); + } + + /** + * Shorthand to register a PATCH route. + */ + public function patch(string $route, callable $handler): Router + { + return $this->add('PATCH', $route, $handler); + } + + /** + * Validate the given method against valid HTTP verbs. + */ + private function validateMethod(string $method): void + { + if (!in_array($method, self::VALID_METHODS)) { + throw new InvalidArgumentException("Invalid HTTP method: $method"); + } + } + + /** + * Validate that a new route follows expected formatting. + */ + private function validateRoute(string $route): void + { + if ($route === '') { + throw new InvalidArgumentException("Route cannot be empty"); + } + + // Ensure route starts with a slash + if (!str_starts_with($route, '/')) { + throw new InvalidArgumentException("Route must start with a '/'"); + } + + // Optional: Check for consecutive dynamic segments or invalid characters + if (preg_match('/(:x.*){2,}/', $route)) { + throw new InvalidArgumentException("Invalid route pattern: consecutive dynamic segments"); + } + } } diff --git a/src/util/auth.php b/src/util/auth.php index f90f2d7..2de78c2 100644 --- a/src/util/auth.php +++ b/src/util/auth.php @@ -9,9 +9,9 @@ function auth_check() if (isset($_SESSION['user'])) return true; if (isset($_COOKIE['remember_me'])) { - $session = session_validate($_COOKIE['remember_me']); - if ($session === true) { - $user = User::find($session['user_id']); + $session = Session::find($_COOKIE['remember_me']); + if ($session->validate()) { + $user = User::find($session->user_id); $_SESSION['user'] = serialize($user); return true; } @@ -54,8 +54,7 @@ function must_have_character() // if no character selected, select the first one if (user()->char_id === 0) { - $char = db_query( - db_live(), + $char = live_db()->query( 'SELECT * FROM characters WHERE user_id = :u ORDER BY id ASC LIMIT 1', [':u' => user()->id] )->fetchArray(SQLITE3_ASSOC); diff --git a/src/util/database.php b/src/util/database.php deleted file mode 100644 index f80875e..0000000 --- a/src/util/database.php +++ /dev/null @@ -1,121 +0,0 @@ -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'); - - return $db; -} - -/** - * Return a connection to the auth database. - */ -function db_auth() -{ - return $GLOBALS['db_auth'] ??= db_open(DBP . '/auth.db'); -} - -/** - * Return a connection to the live database. - */ -function db_live() -{ - return $GLOBALS['db_live'] ??= db_open(DBP . '/live.db'); -} - - -/** - * Return a connection to the fights database. - */ -function db_fights() -{ - return $GLOBALS['db_fights'] ??= db_open(DBP . '/fights.db'); -} - - -/** - * Return a connection to the blueprints database. - */ -function db_blueprints() -{ - return $GLOBALS['db_blueprints'] ??= db_open(DBP . '/blueprints.db'); -} - -/** - * Take a SQLite3 database connection, a query string, and an array of parameters. Prepare the query and - * bind the parameters with proper type casting. Then execute the query and return the result. - */ -function db_query(SQLite3 $db, string $query, array $params = []): SQLite3Result|false -{ - $p = strpos($query, '?') !== false; // are generic placeholders? - $stmt = $db->prepare($query); - if (!empty($params)) { - foreach ($params as $k => $v) $stmt->bindValue($p ? $k + 1 : $k, $v, getSQLiteType($v)); - } - $start = microtime(true); - $r = $stmt->execute(); - db_log($query, microtime(true) - $start); - return $r; -} - -/** - * Take a SQLite3 database connection and a query string. Execute the query and return the result. - */ -function db_exec($db, $query) -{ - $start = microtime(true); - $r = $db->exec($query); - db_log($query, microtime(true) - $start); - return $r; -} - -/** - * Take a SQLite3 database connection, a column name, and a value. Execute a SELECT query to see if the value - * exists in the column. Return true if the value exists, false otherwise. - */ -function db_exists(SQLite3 $db, string $table, string $column, mixed $value, bool $caseInsensitive = true): bool -{ - if ($caseInsensitive) { - $query = "SELECT 1 FROM $table WHERE $column = :v COLLATE NOCASE LIMIT 1"; - } else { - $query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1"; - } - - $result = db_query($db, $query, [':v' => $value]); - return $result->fetchArray(SQLITE3_NUM) !== false; -} - -/** - * Return the appropriate SQLite type casting for the value. - */ -function getSQLiteType($value): int -{ - return match (true) { - is_int($value) => SQLITE3_INTEGER, - is_float($value) => SQLITE3_FLOAT, - is_null($value) => SQLITE3_NULL, - default => SQLITE3_TEXT - }; -} - -/** - * Log the given query string to the db debug log. - */ -function db_log($query, $timeTaken = 0) -{ - $GLOBALS['queries']++; - $GLOBALS['query_time'] += $timeTaken; - if (env('debug', false)) $GLOBALS['query_log'][] = [$query, $timeTaken]; -} diff --git a/src/util/env.php b/src/util/env.php deleted file mode 100644 index 3fc1ab1..0000000 --- a/src/util/env.php +++ /dev/null @@ -1,47 +0,0 @@ - true, - $v === 'false' => false, - is_numeric($v) => (int) $v, - is_float($v) => (float) $v, - default => $v - }; -} diff --git a/src/util/render.php b/src/util/render.php deleted file mode 100644 index 9072796..0000000 --- a/src/util/render.php +++ /dev/null @@ -1,20 +0,0 @@ - -
-
-
- name ?> - Llevel ?> title()['name'] ?> + -
-
- -
+
+ -
-
- -
-
+
+ (level ?>) name ?>, title()['name'] ?> - -
+
+
+ +
-
+
+
+ +
+ + - -
+