diff --git a/color.php b/color.php index ea3c0f3..d260e14 100644 --- a/color.php +++ b/color.php @@ -4,12 +4,12 @@ * A collection of functions to colorize the output of the terminal. */ -function c(string $c, string $s): string { return $c . $s . "\033[0m"; } -function black(string $s): string { return c("\033[30m", $s); } -function red(string $s): string { return c("\033[31m", $s); } -function green(string $s): string { return c("\033[32m", $s); } -function yellow(string $s): string { return c("\033[33m", $s); } -function blue(string $s): string { return c("\033[34m", $s); } -function magenta(string $s): string { return c("\033[35m", $s); } -function cyan(string $s): string { return c("\033[36m", $s); } -function white(string $s): string { return c("\033[37m", $s); } +function c($c, $s) { return $c . $s . "\033[0m"; } +function black($s) { return c("\033[30m", $s); } +function red($s) { return c("\033[31m", $s); } +function green($s) { return c("\033[32m", $s); } +function yellow($s) { return c("\033[33m", $s); } +function blue($s) { return c("\033[34m", $s); } +function magenta($s) { return c("\033[35m", $s); } +function cyan($s) { return c("\033[36m", $s); } +function white($s) { return c("\033[37m", $s); } diff --git a/database/auth.db b/database/auth.db index 6c95f5e..26b28c4 100644 Binary files a/database/auth.db and b/database/auth.db differ diff --git a/database/live.db b/database/live.db index 28135b9..cced5b2 100644 Binary files a/database/live.db and b/database/live.db differ diff --git a/database/scripts/create.php b/database/scripts/create.php index 3c4b5cb..82c083e 100644 --- a/database/scripts/create.php +++ b/database/scripts/create.php @@ -15,7 +15,7 @@ const BPS = 'blueprints.db'; /** * Echo a string with a newline. */ -function eln(string $string): void +function eln($string) { echo $string . PHP_EOL; } @@ -63,7 +63,7 @@ if ($database === AUTH || $database === 'reset') { password TEXT NOT NULL, auth INT NOT NULL DEFAULT 0, char_id INTEGER NOT NULL DEFAULT 0, - char_slots INTEGER NOT NULL DEFAULT 3, + char_slots INTEGER NOT NULL DEFAULT 300, created DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME DEFAULT CURRENT_TIMESTAMP )'); @@ -656,7 +656,7 @@ if ($database === LIVE || $database === 'reset') { if ($database !== 'reset') exit(0); } -function created_or_error(bool $result, string $table): void +function created_or_error($result, $table) { if ($result === false) { eln(red('Failed to create table: ') . $table); diff --git a/docs/captcha.md b/docs/captcha.md new file mode 100644 index 0000000..3482b5b --- /dev/null +++ b/docs/captcha.md @@ -0,0 +1,56 @@ +Here is some example code for implementing a CAPTCHA, using the gd extension to create a server-rendered, randomized image. + +```php + +``` + +https://github.com/kawaiidesune/zxx diff --git a/public/assets/css/dragon.css b/public/assets/css/dragon.css index bb070f5..45fd73d 100644 --- a/public/assets/css/dragon.css +++ b/public/assets/css/dragon.css @@ -136,7 +136,19 @@ aside#left { &:hover, &.active { color: white; - background-color: black; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.3); + } + + &.active { + background-color: #444c55; + color: #ffffff; + background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1)); + border: 1px solid; + border-color: #3D444C #2F353B #2C3137; + box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset; } } } @@ -182,6 +194,7 @@ span.badge { color: #111111; border-radius: 0.25rem; padding: 0.1rem 0.25rem; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) inset; &.dark { background-color: #444c55; @@ -343,3 +356,14 @@ h1:has(.badge), h2:has(.badge), h3:has(.badge), h4:has(.badge), h5:has(.badge), border-color: #b3b3b3; } } + +a { + color: #4C0515; + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: #6C0515; + text-decoration: underline; + } +} diff --git a/public/assets/css/forms.css b/public/assets/css/forms.css index 65e6ad7..8e2754e 100644 --- a/public/assets/css/forms.css +++ b/public/assets/css/forms.css @@ -3,99 +3,51 @@ outline: none; display: block; width: 100%; - height: 34px; - padding: 6px 12px; - font-size: 14px; - line-height: 1.42857143; - color: #555555; - background-color: #fff; - background-image: none; - border: 1px solid #ccc; + padding: 0.5rem; + color: white; + background-color: rgba(0, 0, 0, 0.2); + border: 1px solid transparent; border-radius: 4px; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; - -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; - -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; - transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; - transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; - transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; -} + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.1); + font-size: 1rem; -/* -.radio-block { - & > input[type="radio"] { - display: none; + &::placeholder { + color: rgba(255, 255, 255, 0.7); } - & > label { - cursor: pointer; - display: inline-block; - border: none; - font-size: 1rem; - background: #f7f8fa linear-gradient(rgba(255, 255, 255, 0), rgba(0, 0, 0, 0.1)); - box-shadow: 0 1px 0 1px rgba(255, 255, 255, 0.3) inset, 0 0 0 1px #adb2bb inset; - color: #111111; - padding: 0.5rem 1rem 0.5rem; - text-align: center; - border-radius: 3px; - user-select: none; - text-decoration: none; - transition: opacity 0.1s ease, background-color 0.1s ease, color 0.1s ease, background 0.1s ease; - -webkit-tap-highlight-color: transparent; + &:hover { + background-color: rgba(0, 0, 0, 0.3); + } + + &:focus { + background-color: rgba(0, 0, 0, 0.5); + border-color: rgba(0, 0, 0, 0.8); + } + + &.error { + background-color: rgba(255, 43, 43, 0.2); &:hover { - background-color: #e0e0e0; - background-image: linear-gradient(rgba(255, 255, 255, 0), rgba(0, 0, 0, 0.1)); - box-shadow: 0 1px 0 1px rgba(255, 255, 255, 0.3) inset, 0 0 0 1px #adb2bb inset; - color: rgba(0, 0, 0, 0.8); + background-color: rgba(255, 43, 43, 0.3); } - & > .badge { - background-color: #444c55; - color: white; + &:focus { + background-color: rgba(255, 43, 43, 0.3); + border-color: rgba(255, 43, 43, 0.8); } } - - &.active > label { - background-color: #444c55; - color: #ffffff; - background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1)); - border: 1px solid; - border-color: #3D444C #2F353B #2C3137; - box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset; - - & > .badge { - background-color: #f7f8fa; - color: #111111; - } - } - - /* When the radio button is checked, change the background color of the label - & > input[type="radio"]:checked + label { - background-color: #5a9bd4; - background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(60, 100, 150, 0.1)); - box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset; - border: 1px solid; - border-color: #4a8ab0 #3a7a9c #2a6a88; - } - - /* When the radio button is disabled, show a normal cursor - & > input[type="radio"]:disabled + label { - cursor: default; - } } -*/ -.radio-block-2 { - & > input[type="radio"] { - display: none; - } +.form.group { + margin-bottom: 1rem; & > label { - cursor: pointer; display: block; + margin-bottom: 0.5rem; + } + & > .form.control:not(:last-child) { + margin-bottom: 0.5rem; } } @@ -113,28 +65,48 @@ width: 100%; border-radius: 0.15rem; cursor: pointer; - transition: color, background-color 0.2s ease; + transition: color, background-color, border-color, background-image 0.2s ease; padding: 0.5rem; + border: 1px solid transparent; + background-image: linear-gradient(rgba(255, 255, 255, 0), rgba(0, 0, 0, 0)); &:hover { - background-color: black; + background-color: rgba(0, 0, 0, 0.3); color: white; } & > .badge { margin-left: 0.25rem; } + + & > span.selected { + display: none; + margin-left: auto; + color: #a6e3a1; + } } &.active > label { - background-color: black; - color: white; + background-color: #444c55; + color: #ffffff; + background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1)); + border: 1px solid; + border-color: #3D444C #2F353B #2C3137; + box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset; + + & > span.selected { + display: inline-block; + } } /* When the radio button is checked, change the background color of the label */ & > input[type="radio"]:checked + label { background-color: #f4cc67; + background-image: linear-gradient(rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.1)); + box-shadow: 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset; color: #111111; + border: 1px solid; + border-color: #C59F43 #AA8326 #957321; } /* When the radio button is disabled, show a normal cursor */ @@ -142,3 +114,8 @@ cursor: default; } } + +/* If there is no character selected, hide the buttons */ +.character-select:not(:has(input[type="radio"]:checked)) > .buttons { + display: none; +} diff --git a/src/auth.php b/src/auth.php index 9b503d4..fd36f06 100644 --- a/src/auth.php +++ b/src/auth.php @@ -3,7 +3,7 @@ /** * Checks if the given username already exists. */ -function auth_username_exists(string $username): bool +function auth_username_exists($username) { return db_exists(db_auth(), 'users', 'username', $username); } @@ -11,7 +11,7 @@ function auth_username_exists(string $username): bool /** * Checks if the given email already exists. */ -function auth_email_exists(string $email): bool +function auth_email_exists($email) { return db_exists(db_auth(), 'users', 'email', $email); } @@ -20,7 +20,7 @@ function auth_email_exists(string $email): bool * 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(): bool +function auth_check() { if (isset($_SESSION['user'])) return true; @@ -42,7 +42,7 @@ function auth_check(): bool * 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(): void +function auth_only() { if (!auth_check()) redirect('/auth/login'); } @@ -50,7 +50,7 @@ function auth_only(): void /** * If there is a user logged in, redirect to the home page. Used for when we have a guest-only page. */ -function guest_only(): void +function guest_only() { if (auth_check()) redirect('/'); } @@ -59,7 +59,7 @@ function guest_only(): void * 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(): void +function must_have_character() { // If there is a character selected, make sure the session is up to date. if ($_SESSION['user']['char_id'] !== 0) { diff --git a/src/bootstrap.php b/src/bootstrap.php index 0dbdd06..38414b3 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -11,6 +11,7 @@ require_once SRC . '/database.php'; require_once SRC . '/auth.php'; require_once SRC . '/router.php'; require_once SRC . '/components.php'; +require_once SRC . '/render.php'; // Database models require_once SRC . '/models/user.php'; @@ -39,5 +40,8 @@ csrf(); // Have a global counter for queries $GLOBALS['queries'] = 0; +// Set the default page layout +page_layout('basic'); + // Run auth_check to see if we're logged in, since it populates the user data in SESSION auth_check(); diff --git a/src/components.php b/src/components.php index f03cee1..df713ae 100644 --- a/src/components.php +++ b/src/components.php @@ -8,7 +8,7 @@ const nav_tabs = [ /** * Render the logout button's form. */ -function c_logout_button(): string +function c_logout_button() { return render('components/logout_button'); } @@ -17,24 +17,31 @@ function c_logout_button(): string * Render the character bar. Relies on there being a character in the session. Without one, this will return an empty * string. */ -function c_char_bar(): string +function c_char_bar() { if (char() === false) return ''; return render('components/char_bar', ['char' => char()]); } /** - * Render the left sidebar navigation menu. Provide the active tab to highlight it. + * Render the left sidebar navigation menu. Provide the active tab to highlight it. Will retrieve the current + * tab from the GLOBALS. */ -function c_left_nav(int $activeTab): string +function c_left_nav() { - return render('components/left_nav', ['activeTab' => $activeTab]); + $tab = match ($GLOBALS['active_nav_tab'] ?? '') { + 'home' => 0, + 'chars' => 1, + default => 0 + }; + + return render('components/left_nav', ['ant' => $tab]); } /** * Render the debug query log. */ -function c_debug_query_log(): string +function c_debug_query_log() { return render('components/debug_query_log'); } @@ -42,7 +49,7 @@ function c_debug_query_log(): string /** * Render the character select radio buttons. */ -function c_char_select_box(int $id, array $char): string +function c_char_select_box($id, array $char) { return render('components/char_select_box', ['id' => $id, 'char' => $char]); } @@ -50,15 +57,34 @@ function c_char_select_box(int $id, array $char): string /** * Render an alert with a given type and message. */ -function c_alert(string $t, string $m): string +function c_alert($t, $m) { return "
$message
"; + return c_alert('danger', $html); +} + +/** + * Generate a text form field. This component will automatically add the error class if there are errors for this field, + * depending on the form-errors GLOBAL. Pass an optional form ID to target a specific form GLOBAL. + */ +function c_form_field($type, $name, $placeholder, $required = false, $autocomplete = "off", $formId = '') +{ + $errors = $GLOBALS[($formId !== '' ? "form-errors-$formId" : 'form-errors')] ?? false; + $html = ""; } diff --git a/src/controllers/auth.php b/src/controllers/auth.php index e6eddc5..b7cd9f6 100644 --- a/src/controllers/auth.php +++ b/src/controllers/auth.php @@ -3,7 +3,7 @@ /** * Displays the registration page. */ -function auth_controller_register_get(): void +function auth_controller_register_get() { guest_only(); echo render('layouts/basic', ['view' => 'pages/auth/register']); @@ -12,20 +12,16 @@ function auth_controller_register_get(): void /** * Handles the registration form submission. */ -function auth_controller_register_post(): void +function auth_controller_register_post() { guest_only(); csrf_ensure(); $errors = []; - $u = $_POST['username'] ?? ''; - $e = $_POST['email'] ?? ''; - $p = $_POST['password'] ?? ''; - - // Trim the input. - $u = trim($u); - $e = trim($e); + $u = trim($_POST['u'] ?? ''); + $e = trim($_POST['e'] ?? ''); + $p = $_POST['p'] ?? ''; /* A username is required. @@ -54,12 +50,6 @@ function auth_controller_register_post(): void $errors['p'][] = 'Password is required and must be at least 6 characters long.'; } - // If there are errors at this point, send them to the page with errors flashed. - if (!empty($errors)) { - flash('alert-registration', ['errors', $errors]); - redirect('/auth/register'); - } - /* A username must be unique. */ @@ -76,8 +66,9 @@ function auth_controller_register_post(): void // If there are errors at this point, send them to the page with errors flashed. if (!empty($errors)) { - flash('alert-registration', ['errors', $errors]); - redirect('/auth/register'); + $GLOBALS['form-errors'] = $errors; + echo page('auth/register'); + exit; } $user = user_create($u, $e, $p); @@ -91,7 +82,7 @@ function auth_controller_register_post(): void /** * Displays the login page. */ -function auth_controller_login_get(): void +function auth_controller_login_get() { guest_only(); echo render('layouts/basic', ['view' => 'pages/auth/login']); @@ -100,48 +91,35 @@ function auth_controller_login_get(): void /** * Handles the login form submission. */ -function auth_controller_login_post(): void +function auth_controller_login_post() { guest_only(); csrf_ensure(); $errors = []; - $u = $_POST['username'] ?? ''; - $p = $_POST['password'] ?? ''; + $u = trim($_POST['u'] ?? ''); + $p = $_POST['p'] ?? ''; - // Trim the input. - $u = trim($u); - - /* - A username is required. - */ - if (empty($u)) { - $errors['u'][] = 'Username is required.'; - } - - /* - A password is required. - */ - if (empty($p)) { - $errors['p'][] = 'Password is required.'; - } + if (empty($u)) $errors['u'][] = 'Username is required.'; + if (empty($p)) $errors['p'][] = 'Password is required.'; // If there are errors at this point, send them to the page with errors flashed. if (!empty($errors)) { - flash('errors', $errors); - redirect('/auth/login'); + $GLOBALS['form-errors'] = $errors; + echo render('layouts/basic', ['view' => 'pages/auth/login']); + exit; } $user = user_find($u); if ($user === false || !password_verify($p, $user['password'])) { - $errors['u'][] = 'Invalid username or password.'; - flash('errors', $errors); - redirect('/auth/login'); + $errors['x'][] = 'Invalid username or password.'; + $GLOBALS['form-errors'] = $errors; + echo render('layouts/basic', ['view' => 'pages/auth/login']); + exit; } $_SESSION['user'] = $user; - change_user_character($user['char_id']); if ($_POST['remember'] ?? false) { $token = token(); @@ -155,13 +133,19 @@ function auth_controller_login_post(): void set_cookie('remember_me', $token, $expires); } + if (char_count($_SESSION['user']['id']) === 0) { + redirect('/character/create-first'); + } elseif (!change_user_character($_SESSION['user']['char_id'])) { + router_error(999); + } + redirect('/'); } /** * Logs the user out. */ -function auth_controller_logout_post(): void +function auth_controller_logout_post() { csrf_ensure(); session_delete($_SESSION['user']['id']); diff --git a/src/controllers/char.php b/src/controllers/char.php index cf45db0..c636e1a 100644 --- a/src/controllers/char.php +++ b/src/controllers/char.php @@ -3,24 +3,22 @@ /** * Display a list of characters for the currently logged in user. */ -function char_controller_list_get(): void +function char_controller_list_get() { - auth_only(); - must_have_character(); + auth_only(); must_have_character(); - $chars = char_list(user('id')); - - echo render('layouts/basic', ['view' => 'pages/chars/list', 'chars' => $chars, 'activeTab' => nav_tabs['chars']]); + $GLOBALS['active_nav_tab'] = 'chars'; + echo page('chars/list', ['chars' => char_list(user('id'))]); } /** * Handle an action from the character list page. */ -function char_controller_list_post(): void +function char_controller_list_post() { - auth_only(); - must_have_character(); - csrf_ensure(); + auth_only(); must_have_character(); csrf_ensure(); + + $GLOBALS['active_nav_tab'] = 'chars'; $char_id = (int) ($_POST['char_id'] ?? 0); $action = $_POST['action'] ?? ''; @@ -28,6 +26,12 @@ function char_controller_list_post(): void // If the character ID is not a number, or the action is not a string, return a 400. if (!is_numeric($char_id) || !is_string($action)) router_error(400); + // If the character ID is 0, return to the list. + if ($char_id === 0) { + flash('alert_character_list_1', ['', 'No character selected.']); + redirect('/characters'); + } + // If the action is not one of the allowed actions, return a 400. if (!in_array($action, ['select', 'delete'])) router_error(400); @@ -35,33 +39,25 @@ function char_controller_list_post(): void if ($action === 'select') { // If the character ID is the current character, do nothing. if ($char_id === $_SESSION['user']['char_id'] || $char_id === 0) { - flash('info', 'You are already using that character.'); + flash('alert_character_list_1', ['info', 'You are already using ' . char('name') . '.']); redirect('/characters'); } - // Make sure the character ID is valid. - if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (acccp)'); - - // Make sure the user owns the character. + // Ensure the character ID is valid and belongs to the user. if (!char_belongs_to_user($char_id, $_SESSION['user']['id'])) router_error(999); change_user_character($char_id); - flash('success', 'Switched to character ' . char('name') . '!'); + flash('alert_character_list_1', ['success', 'Switched to character ' . char('name') . '!']); } // If the action is to delete a character, move to the confirmation page. if ($action === 'delete') { - // Make sure the character ID is valid. - if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (accdp)'); - - // Make sure the user owns the character. + // Ensure the character ID is valid and belongs to the user. if (!char_belongs_to_user($char_id, $_SESSION['user']['id'])) router_error(999); - $char = char_find($char_id); - - echo render('layouts/basic', ['view' => 'pages/chars/delete', 'char' => $char, 'activeTab' => nav_tabs['chars']]); - return; + echo page('chars/delete', ['char' => char_find($char_id)]); + exit; } redirect('/characters'); @@ -70,29 +66,23 @@ function char_controller_list_post(): void /** * Delete a character for the currently logged in user. */ -function char_controller_delete_post(): void +function char_controller_delete_post() { - auth_only(); - must_have_character(); - csrf_ensure(); + auth_only(); must_have_character(); csrf_ensure(); $char_id = (int) ($_POST['char_id'] ?? 0); - $name = $_POST['name'] ?? ''; // If the character ID is not a number, return a 400. if (!is_numeric($char_id)) router_error(400); - // Make sure the character ID is valid. - if (char_exists($char_id) === false) throw new Exception('Invalid character ID. (acddp)'); - - // Make sure the user owns the character. + // Ensure the character ID is valid and belongs to the user. if (!char_belongs_to_user($char_id, $_SESSION['user']['id'])) router_error(999); $char = char_find($char_id); - // Confirm the name matches the name of the character. - if ($char['name'] !== $_POST['name']) { - flash('error', 'Failed to delete character. Name confirmation did not match.'); + // Confirm the name matches the name of the character. CASE SENSITIVE. + if ($char['name'] !== trim($_POST['n'] ?? '')) { + flash('alert_character_list_1', ['danger', 'Failed to delete ' . $char['name'] . '. Name confirmation did not match.']); redirect('/characters'); } @@ -105,37 +95,37 @@ function char_controller_delete_post(): void if (count($chars) > 0) change_user_character($chars[0]['id']); } - flash('error', 'Character ' . $char['name'] . ' deleted.'); + flash('alert_character_list_1', ['danger', 'Character ' . $char['name'] . ' deleted.']); redirect('/characters'); } /** * Form to create your first character. */ -function char_controller_create_first_get(): void +function char_controller_create_first_get() { auth_only(); + $GLOBALS['active_nav_tab'] = 'chars'; + // If the user already has a character, redirect them to the main page. if (char_count(user('id')) > 0) redirect('/'); - echo render('layouts/basic', ['view' => 'pages/chars/first', 'activeTab' => nav_tabs['chars']]); + echo page('chars/first'); } /** * Create a character for the currently logged in user. */ -function char_controller_create_post(): void +function char_controller_create_post() { - auth_only(); - csrf_ensure(); + auth_only(); csrf_ensure(); + + $GLOBALS['active_nav_tab'] = 'chars'; $errors = []; - $name = $_POST['name'] ?? ''; - - // Trim the input. - $name = trim($name); + $name = trim($_POST['n'] ?? ''); /* A name is required. @@ -143,18 +133,27 @@ function char_controller_create_post(): void A name must contain only alphanumeric characters and spaces. */ if (empty($name) || strlen($name) < 3 || strlen($name) > 18 || !ctype_alnum(str_replace(' ', '', $name))) { - $errors['name'][] = 'Name is required and must be between 3 and 18 characters long and contain only alphanumeric characters and spaces.'; + $errors['n'][] = 'Name is required and must be between 3 and 18 characters long and contain only alphanumeric characters and spaces.'; } /* A character's name must be unique. */ - if (char_name_exists($name)) $errors['name'][] = 'Name is already taken.'; + if (char_name_exists($name)) $errors['n'][] = 'Name is already taken.'; // If there are errors at this point, send them to the page with errors flashed. if (!empty($errors)) { - flash('alert-cl2', ['errors', $errors]); - redirect('/characters'); + $GLOBALS['form-errors-create-character'] = $errors; + + if (isset($_POST['first']) && $_POST['first'] === 'true') { + // If this is the first character, return to the first character creation page. + echo page('chars/first'); + exit; + } else { + // If this is not the first character, return to the character list page. + echo page('chars/list', ['chars' => char_list(user('id'))]); + exit; + } } // Create the character @@ -168,7 +167,7 @@ function char_controller_create_post(): void // Set the character as the user's selected character change_user_character($char); - flash('alert_character_list_1', ['success', 'Character ' . $name . ' created!']); + flash('alert_character_list_1', ['success', 'Character ' . $name . ' created!']); redirect('/characters'); } diff --git a/src/database.php b/src/database.php index 5382ae3..d5b5a34 100644 --- a/src/database.php +++ b/src/database.php @@ -3,7 +3,7 @@ /** * Open a connection to a database. */ -function db_open(string $path): SQLite3 +function db_open($path): SQLite3 { $db = new SQLite3($path); @@ -55,11 +55,10 @@ function db_blueprints(): SQLite3 * 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 +function db_query(SQLite3 $db, $query, array $params = []) { $stmt = $db->prepare($query); if (!empty($params)) foreach ($params as $key => $value) $stmt->bindValue($key, $value, getSQLiteType($value)); - $GLOBALS['queries']++; db_log($query); return $stmt->execute(); } @@ -67,9 +66,8 @@ function db_query(SQLite3 $db, string $query, array $params = []): SQLite3Result /** * Take a SQLite3 database connection and a query string. Execute the query and return the result. */ -function db_exec(SQLite3 $db, string $query): bool +function db_exec(SQLite3 $db, $query) { - $GLOBALS['queries']++; db_log($query); return $db->exec($query); } @@ -78,18 +76,22 @@ function db_exec(SQLite3 $db, string $query): bool * Take a SQLite3 database connection, a column name, and a value. Execute a COUNT 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 +function db_exists(SQLite3 $db, $table, $column, $value, $caseInsensitive = true) { - $query = "SELECT 1 FROM $table WHERE $column = :v LIMIT 1"; + 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]); - db_log($query); return $result->fetchArray(SQLITE3_NUM) !== false; } /** * Return the appropriate SQLite type casting for the value. */ -function getSQLiteType(mixed $value): int +function getSQLiteType($value): int { return match (true) { is_int($value) => SQLITE3_INTEGER, @@ -102,7 +104,8 @@ function getSQLiteType(mixed $value): int /** * Log the given query string to the db debug log. */ -function db_log(string $query): void +function db_log($query) { + $GLOBALS['queries']++; if (env('debug', false)) $GLOBALS['query_log'][] = $query; } diff --git a/src/env.php b/src/env.php index 49496f7..d09dcdd 100644 --- a/src/env.php +++ b/src/env.php @@ -3,7 +3,7 @@ /** * Load the environment variables from the .env file. */ -function env_load(string $filePath): void +function env_load($filePath) { if (!file_exists($filePath)) throw new Exception("The .env file does not exist. (el)"); @@ -34,7 +34,7 @@ function env_load(string $filePath): void /** * Retrieve an environment variable. */ -function env(string $key, mixed $default = null): mixed +function env($key, $default = null) { return $_ENV[$key] ?? $_SERVER[$key] ?? (getenv($key) ?: $default); } diff --git a/src/helpers.php b/src/helpers.php index 6582e16..6b31a1d 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,28 +1,9 @@ $_) { if (str_starts_with($key, 'flash_')) unset($_SESSION[$key]); @@ -59,7 +48,7 @@ function clear_flashes(): void /** * Create a CSRF token. */ -function csrf(): string +function csrf() { if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = token(); return $_SESSION['csrf']; @@ -68,7 +57,7 @@ function csrf(): string /** * Verify a CSRF token. */ -function csrf_verify(string $token): bool +function csrf_verify($token) { if (hash_equals($_SESSION['csrf'] ?? '', $token)) { $_SESSION['csrf'] = token(); @@ -81,7 +70,7 @@ function csrf_verify(string $token): bool /** * Create a hidden input field for CSRF tokens. */ -function csrf_field(): string +function csrf_field() { return ''; } @@ -89,7 +78,7 @@ function csrf_field(): string /** * Kill the current request with a 418 error, if $_POST['csrf'] is invalid. */ -function csrf_ensure(): void +function csrf_ensure() { if (!csrf_verify($_POST['csrf'] ?? '')) router_error(418); } @@ -97,7 +86,7 @@ function csrf_ensure(): void /** * Set a cookie with secure and HTTP-only flags. */ -function set_cookie(string $name, string $value, int $expires): void +function set_cookie($name, $value, $expires) { setcookie($name, $value, [ 'expires' => $expires, @@ -112,7 +101,7 @@ function set_cookie(string $name, string $value, int $expires): void /** * Get the current user's array from SESSION if it exists. Specify a key to get a specific value. */ -function user(string $field = ''): mixed +function user($field = '') { if (empty($_SESSION['user'])) return false; if ($field === '') return $_SESSION['user']; @@ -131,7 +120,7 @@ function user_selected_char(): int * If the current user has a selected char and the data is in the session, retrieve either the full array of data * or a specific field. If there is no character data, populate it. */ -function char(string $field = ''): mixed +function char($field = '') { // If there is no user, return false if (empty($_SESSION['user'])) return false; @@ -149,19 +138,28 @@ function char(string $field = ''): mixed } /** - * Shorthand to update the user's selected character. + * Shorthand to update the user's selected character. Returns true on success, false on failure. Database + * is updated if the character ID is different from the current session. */ -function change_user_character(int $char_id): void +function change_user_character($char_id) { - $_SESSION['user']['char_id'] = $char_id; - db_query(db_auth(), "UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user('id')]); - $GLOBALS['char'] = char_find($char_id); + // If the character does not exist, return false + if (($char = char_find($char_id)) === false) return false; + $GLOBALS['char'] = $char; + + // If the character ID is different, update the session and database + if ($_SESSION['user']['char_id'] !== $char_id) { + $_SESSION['user']['char_id'] = $char_id; + db_query(db_auth(), "UPDATE users SET char_id = :c WHERE id = :u", [':c' => $char_id, ':u' => user('id')]); + } + + return true; } /** * Get a percent between two ints, rounded to the nearest whole number or return 0. */ -function percent(int $num, int $denom, int $precision = 4): int +function percent($num, $denom, $precision = 4): int { if ($denom === 0) return 0; $p = ($num / $denom) * 100; @@ -173,7 +171,7 @@ function percent(int $num, int $denom, int $precision = 4): int * the data is up to date with every request without having to query the database every use within, for example, a * template. Will return false if the field does not exist, or the entire wallet array if no field is specified. */ -function wallet(string $field = ''): array|int|false +function wallet($field = ''): array|int { if (empty($GLOBALS['wallet'])) { $GLOBALS['wallet'] = db_query( @@ -190,7 +188,7 @@ function wallet(string $field = ''): array|int|false /** * Format an array of strings to a ul element. */ -function array_to_ul(array $array): string +function array_to_ul(array $array) { $html = ''; foreach ($array as $item) $html .= "+ Welcome back, adventurer! Your journey continues here. Login to your account and pick up where you left off. +
+ + = c_form_errors() ?>