diff --git a/assets/less/mio/classes/navigation.less b/assets/less/mio/classes/navigation.less index dd2da89e..db5af422 100644 --- a/assets/less/mio/classes/navigation.less +++ b/assets/less/mio/classes/navigation.less @@ -26,8 +26,10 @@ border-top-width: 1px; border-bottom-width: 0; - &--selected { - top: 1px; + @media (min-width: @mio-navigation-mobile) { + &--selected { + top: 1px; + } } } } diff --git a/assets/less/mio/classes/settings/account.less b/assets/less/mio/classes/settings/account.less new file mode 100644 index 00000000..f99137c9 --- /dev/null +++ b/assets/less/mio/classes/settings/account.less @@ -0,0 +1,64 @@ +@mio-settings-account-mobile: 800px; + +.mio__settings__account { + display: flex; + flex-direction: column; + + &__row { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-evenly; + + @media (max-width: @mio-settings-account-mobile) { + flex-direction: column; + } + + &--buttons { + display: block; + text-align: center; + margin: 2px 0; + } + } + + &__column { + flex-grow: 1; + + &:not(.mio__settings__account__column--no-margin) { + margin: 1px; + border: 1px solid #9475b2; + border-top-width: 0; + } + } + + &__title { + background-color: #9475b2; + color: #306; + font-size: 1.1em; + font-weight: 700; + padding: 3px; + font-family: Verdana, Arial, Helvetica, sans-serif; + } + + &__input { + display: flex; + margin: 2px; + + @media (max-width: @mio-settings-account-mobile) { + flex-direction: column; + } + + &__name { + margin: 0 2px; + min-width: 140px; + } + + &__value { + flex-grow: 1; + + &__text { + width: 100%; + } + } + } +} diff --git a/assets/less/mio/classes/settings/content.less b/assets/less/mio/classes/settings/content.less new file mode 100644 index 00000000..27acb44f --- /dev/null +++ b/assets/less/mio/classes/settings/content.less @@ -0,0 +1,5 @@ +.mio__settings__content { + &--account { + margin: 1px; + } +} diff --git a/assets/less/mio/classes/settings/errors.less b/assets/less/mio/classes/settings/errors.less new file mode 100644 index 00000000..fc15bfc8 --- /dev/null +++ b/assets/less/mio/classes/settings/errors.less @@ -0,0 +1,4 @@ +.mio__settings__errors { + margin-left: 1.2em; + list-style: square; +} diff --git a/assets/less/mio/main.less b/assets/less/mio/main.less index ecb60d22..d3550205 100644 --- a/assets/less/mio/main.less +++ b/assets/less/mio/main.less @@ -48,5 +48,10 @@ body { @import "classes/navigation"; @import "classes/profile"; +// Settings +@import "classes/settings/content"; +@import "classes/settings/errors"; +@import "classes/settings/account"; + // Forums @import "classes/forum/listing"; diff --git a/database/2018_03_22_201221_add_session_country_and_login_user_agent.php b/database/2018_03_22_201221_add_session_country_and_login_user_agent.php new file mode 100644 index 00000000..d7d78584 --- /dev/null +++ b/database/2018_03_22_201221_add_session_country_and_login_user_agent.php @@ -0,0 +1,38 @@ +getSchemaBuilder(); + $schema->table('sessions', function (Blueprint $table) { + $table->char('session_country', 2) + ->default('XX'); + }); + $schema->table('login_attempts', function (Blueprint $table) { + $table->string('user_agent', 255) + ->default(''); + }); + } + + /** + * @SuppressWarnings(PHPMD) + */ + public function down() + { + $schema = Database::connection()->getSchemaBuilder(); + $schema->table('sessions', function (Blueprint $table) { + $table->dropColumn('session_country'); + }); + $schema->table('login_attempts', function (Blueprint $table) { + $table->dropColumn('user_agent'); + }); + } +} diff --git a/database/2018_03_22_232723_add_profile_fields_to_users.php b/database/2018_03_22_232723_add_profile_fields_to_users.php new file mode 100644 index 00000000..86521459 --- /dev/null +++ b/database/2018_03_22_232723_add_profile_fields_to_users.php @@ -0,0 +1,69 @@ +getSchemaBuilder(); + $schema->table('users', function (Blueprint $table) { + $table->string('user_website', 255) + ->default(''); + + $table->string('user_twitter', 20) + ->default(''); + + $table->string('user_github', 40) + ->default(''); + + $table->string('user_skype', 60) + ->default(''); + + $table->string('user_discord', 40) + ->default(''); + + $table->string('user_youtube', 255) + ->default(''); + + $table->string('user_steam', 255) + ->default(''); + + $table->string('user_twitchtv', 30) + ->default(''); + + $table->string('user_osu', 20) + ->default(''); + + $table->string('user_lastfm', 20) + ->default(''); + }); + } + + /** + * @SuppressWarnings(PHPMD) + */ + public function down() + { + $schema = Database::connection()->getSchemaBuilder(); + $schema->table('users', function (Blueprint $table) { + $table->dropColumn([ + 'user_website', + 'user_twitter', + 'user_github', + 'user_skype', + 'user_discord', + 'user_youtube', + 'user_steam', + 'user_twitchtv', + 'user_osu', + 'user_lastfm', + ]); + }); + } +} diff --git a/public/auth.php b/public/auth.php index 5b6fb636..b732203b 100644 --- a/public/auth.php +++ b/public/auth.php @@ -17,6 +17,7 @@ $username_validation_errors = [ 'double-spaces' => "Your username can't contain double spaces.", 'invalid' => 'Your username contains invalid characters.', 'spacing' => 'Please use either underscores or spaces, not both!', + 'in-use' => 'This username is already taken!', ]; $mode = $_GET['m'] ?? 'login'; @@ -59,6 +60,7 @@ switch ($mode) { break; } + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $auth_login_error = ''; while ($_SERVER['REQUEST_METHOD'] === 'POST') { @@ -85,20 +87,20 @@ switch ($mode) { try { $user = User::where('username', $username)->orWhere('email', $username)->firstOrFail(); } catch (ModelNotFoundException $e) { - LoginAttempt::recordFail($ipAddress); + LoginAttempt::recordFail($ipAddress, null, $user_agent); $auth_login_error = 'Invalid username or password!'; break; } - if (!$user->validatePassword($password)) { - LoginAttempt::recordFail($ipAddress, $user); + if (!$user->verifyPassword($password)) { + LoginAttempt::recordFail($ipAddress, $user, $user_agent); $auth_login_error = 'Invalid username or password!'; break; } - LoginAttempt::recordSuccess($ipAddress, $user); + LoginAttempt::recordSuccess($ipAddress, $user, $user_agent); - $session = Session::createSession($user, 'Misuzu T2', null, $ipAddress); + $session = Session::createSession($user, $user_agent, null, $ipAddress); $app->setSession($session); set_cookie_m('uid', $session->user_id, 604800); set_cookie_m('sid', $session->session_key, 604800); @@ -142,41 +144,24 @@ switch ($mode) { } $username = $_POST['username'] ?? ''; - $username_validate = User::validateUsername($username); $password = $_POST['password'] ?? ''; $email = $_POST['email'] ?? ''; + $username_validate = User::validateUsername($username, true); if ($username_validate !== '') { $auth_register_error = $username_validation_errors[$username_validate]; break; } - try { - $existing = User::where('username', $username)->firstOrFail(); - - if ($existing->user_id > 0) { - $auth_register_error = 'This username is already taken!'; - break; - } - } catch (ModelNotFoundException $e) { - } - - if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !check_mx_record($email)) { - $auth_register_error = 'The e-mail address you entered is invalid!'; + $email_validate = User::validateEmail($email, true); + if ($email_validate !== '') { + $auth_register_error = $email_validate === 'in-use' + ? 'This e-mail address has already been used!' + : 'The e-mail address you entered is invalid!'; break; } - try { - $existing = User::where('email', $email)->firstOrFail(); - - if ($existing->user_id > 0) { - $auth_register_error = 'This e-mail address has already been used!'; - break; - } - } catch (ModelNotFoundException $e) { - } - - if (password_entropy($password) < 32) { + if (User::validatePassword($password) !== '') { $auth_register_error = 'Your password is too weak!'; break; } diff --git a/public/settings.php b/public/settings.php new file mode 100644 index 00000000..f2ea306e --- /dev/null +++ b/public/settings.php @@ -0,0 +1,342 @@ +getSession(); + +if ($settings_session === null) { + header('Location: /'); + return; +} + +$settings_user = $settings_session->user; + +$settings_mode = $_GET['m'] ?? null; +$settings_modes = [ + 'account' => 'Account', + 'avatar' => 'Avatar', + 'sessions' => 'Sessions', + 'login-history' => 'Login History', +]; + +// if no mode is explicitly set just go to the index +if ($settings_mode === null) { + $settings_mode = key($settings_modes); +} + +$app->templating->vars(compact('settings_mode', 'settings_modes', 'settings_user')); + +if (!array_key_exists($settings_mode, $settings_modes)) { + $app->templating->var('settings_title', 'Not Found'); + echo $app->templating->render("settings.notfound"); + return; +} + +$settings_errors = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + switch ($settings_mode) { + case 'account': + if (!tmp_csrf_verify($_POST['csrf'] ?? '')) { + $settings_errors[] = "Couldn't verify you, please refresh the page and retry."; + break; + } + + if (isset($_POST['profile']) && is_array($_POST['profile'])) { + if (isset($_POST['profile']['twitter'])) { + $user_twitter = ''; + + if (!empty($_POST['profile']['twitter'])) { + $twitter_regex = preg_match( + '#^(?:https?://(?:www\.)?twitter.com/(?:\#!\/)?)?@?([A-Za-z0-9_]{1,20})/?$#u', + $_POST['profile']['twitter'], + $twitter_matches + ); + + if ($twitter_regex !== 1) { + $settings_errors[] = "Invalid Twitter field."; + break; + } + + $user_twitter = $twitter_matches[1]; + } + + $settings_user->user_twitter = $user_twitter; + } + + if (isset($_POST['profile']['osu'])) { + $user_osu = ''; + + if (!empty($_POST['profile']['osu'])) { + $osu_regex = preg_match( + '#^(?:https?://osu.ppy.sh/u(?:sers)?/)?([a-zA-Z0-9-\[\]_ ]{1,20})/?$#u', + $_POST['profile']['osu'], + $osu_matches + ); + + if ($osu_regex !== 1) { + $settings_errors[] = "Invalid osu! field."; + break; + } + + $user_osu = $osu_matches[1]; + } + + $settings_user->user_osu = $user_osu; + } + + if (isset($_POST['profile']['website'])) { + $user_website = ''; + + if (!empty($_POST['profile']['website'])) { + $website_regex = preg_match( + '#^(?:https?)://(.{1,240})$#u', + $_POST['profile']['website'], + $website_matches + ); + + if ($website_regex !== 1) { + $settings_errors[] = "Invalid website field."; + break; + } + + $user_website = $website_matches[0]; + } + + $settings_user->user_website = $user_website; + } + + if (isset($_POST['profile']['youtube'])) { + $user_youtube = ''; + + if (!empty($_POST['profile']['youtube'])) { + $youtube_regex = preg_match( + '#^(?:https?://(?:www.)?youtube.com/(?:(?:user|c|channel)/)?)?' + . '(UC[a-zA-Z0-9-_]{1,50}|[a-zA-Z0-9-_%]{1,100})/?$#u', + $_POST['profile']['youtube'], + $youtube_matches + ); + + if ($youtube_regex !== 1) { + $settings_errors[] = "Invalid Youtube field."; + break; + } + + $user_youtube = $youtube_matches[1]; + } + + $settings_user->user_youtube = $user_youtube; + } + + if (isset($_POST['profile']['steam'])) { + $user_steam = ''; + + if (!empty($_POST['profile']['steam'])) { + $steam_regex = preg_match( + '#^(?:https?://(?:www.)?steamcommunity.com/(?:id|profiles)/)?([a-zA-Z0-9_-]{2,100})/?$#u', + $_POST['profile']['steam'], + $steam_matches + ); + + if ($steam_regex !== 1) { + $settings_errors[] = "Invalid Steam field."; + break; + } + + $user_steam = $steam_matches[1]; + } + + $settings_user->user_steam = $user_steam; + } + + if (isset($_POST['profile']['twitchtv'])) { + $user_twitchtv = ''; + + if (!empty($_POST['profile']['twitchtv'])) { + $twitchtv_regex = preg_match( + '#^(?:https?://(?:www.)?twitch.tv/)?([0-9A-Za-z_]{3,25})/?$#u', + $_POST['profile']['twitchtv'], + $twitchtv_matches + ); + + if ($twitchtv_regex !== 1) { + $settings_errors[] = "Invalid Twitch.TV field."; + break; + } + + $user_twitchtv = $twitchtv_matches[1]; + } + + $settings_user->user_twitchtv = $user_twitchtv; + } + + if (isset($_POST['profile']['lastfm'])) { + $user_lastfm = ''; + + if (!empty($_POST['profile']['lastfm'])) { + $lastfm_regex = preg_match( + '#^(?:https?://(?:www.)?last.fm/user/)?([a-zA-Z]{1}[a-zA-Z0-9_-]{1,14})/?$#u', + $_POST['profile']['lastfm'], + $lastfm_matches + ); + + if ($lastfm_regex !== 1) { + $settings_errors[] = "Invalid Last.fm field."; + break; + } + + $user_lastfm = $lastfm_matches[1]; + } + + $settings_user->user_lastfm = $user_lastfm; + } + + if (isset($_POST['profile']['github'])) { + $user_github = ''; + + if (!empty($_POST['profile']['github'])) { + $github_regex = preg_match( + '#^(?:https?://(?:www.)?github.com/?)?' + . '([a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38})/?$#u', + $_POST['profile']['github'], + $github_matches + ); + + if ($github_regex !== 1) { + $settings_errors[] = "Invalid Github field."; + break; + } + + $user_github = $github_matches[1]; + } + + $settings_user->user_github = $user_github; + } + + if (isset($_POST['profile']['skype'])) { + $user_skype = ''; + + if (!empty($_POST['profile']['skype'])) { + $skype_regex = preg_match( + '#^((?:live:)?[a-zA-Z][\w\.,\-_@]{1,100})$#u', + $_POST['profile']['skype'], + $skype_matches + ); + + if ($skype_regex !== 1) { + $settings_errors[] = "Invalid Skype field."; + break; + } + + $user_skype = $skype_matches[1]; + } + + $settings_user->user_skype = $user_skype; + } + + if (isset($_POST['profile']['discord'])) { + $user_discord = ''; + + if (!empty($_POST['profile']['discord'])) { + $discord_regex = preg_match( + '#^(.{1,32}\#[0-9]{4})$#u', + $_POST['profile']['discord'], + $discord_matches + ); + + if ($discord_regex !== 1) { + $settings_errors[] = "Invalid Discord field."; + break; + } + + $user_discord = $discord_matches[1]; + } + + $settings_user->user_discord = $user_discord; + } + } + + if (!empty($_POST['current_password']) && (isset($_POST['password']) || isset($_OST['email']))) { + if (!$settings_user->verifyPassword($_POST['current_password'])) { + $settings_errors[] = "Your current password was incorrect."; + break; + } + + if (!empty($_POST['email']['new'])) { + if (empty($_POST['email']['confirm']) || $_POST['email']['new'] !== $_POST['email']['confirm']) { + $settings_errors[] = "The given e-mail addresses did not match."; + break; + } + + if ($_POST['email']['new'] === $settings_user->email) { + $settings_errors[] = "This is your e-mail address already!"; + break; + } + + $email_validate = User::validateEmail($_POST['email']['new'], true); + + if ($email_validate !== '') { + switch ($email_validate) { + case 'dns': + $settings_errors[] = "No valid MX record exists for this domain."; + break; + + case 'format': + $settings_errors[] = "The given e-mail address was incorrectly formatted."; + break; + + case 'in-use': + $settings_errors[] = "This e-mail address has already been used by another user."; + break; + + default: + $settings_errors[] = "Unknown e-mail validation error."; + } + break; + } + + $settings_user->email = $_POST['email']['new']; + } + + if (!empty($_POST['password']['new'])) { + if (empty($_POST['password']['confirm']) + || $_POST['password']['new'] !== $_POST['password']['confirm']) { + $settings_errors[] = "The given passwords did not match."; + break; + } + + $password_validate = User::validatePassword($_POST['password']['new'], true); + + if ($password_validate !== '') { + $settings_errors[] = "The given passwords was too weak."; + break; + } + + $settings_user->password = $_POST['password']['new']; + } + } + + if (count($settings_errors) < 1 && $settings_user->isDirty()) { + $settings_user->save(); + } + break; + } +} + +$app->templating->vars(compact('settings_errors')); +$app->templating->var('settings_title', $settings_modes[$settings_mode]); + +switch ($settings_mode) { + case 'sessions': + $app->templating->var('user_sessions', $settings_user->sessions->reverse()); + break; + + case 'login-history': + $app->templating->var('user_login_attempts', $settings_user->loginAttempts->reverse()); + break; +} + +echo $app->templating->render("settings.{$settings_mode}"); diff --git a/src/Application.php b/src/Application.php index c1d54380..19402425 100644 --- a/src/Application.php +++ b/src/Application.php @@ -120,7 +120,7 @@ class Application extends ApplicationBase $twig->addFilter('json_decode'); $twig->addFilter('byte_symbol'); $twig->addFilter('country_name', 'get_country_name'); - $twig->addFilter('md5'); // using this for logout CSRF for now, remove this when proper CSRF is in place + $twig->addFilter('flip', 'array_flip'); $twig->addFunction('byte_symbol'); $twig->addFunction('session_id'); diff --git a/src/Users/LoginAttempt.php b/src/Users/LoginAttempt.php index 45b05a59..746fe55a 100644 --- a/src/Users/LoginAttempt.php +++ b/src/Users/LoginAttempt.php @@ -9,21 +9,26 @@ class LoginAttempt extends Model { protected $primaryKey = 'attempt_id'; - public static function recordSuccess(IPAddress $ipAddress, User $user): LoginAttempt + public static function recordSuccess(IPAddress $ipAddress, User $user, ?string $userAgent = null): LoginAttempt { - return static::recordAttempt(true, $ipAddress, $user); + return static::recordAttempt(true, $ipAddress, $user, $userAgent); } - public static function recordFail(IPAddress $ipAddress, ?User $user = null): LoginAttempt + public static function recordFail(IPAddress $ipAddress, ?User $user = null, ?string $userAgent = null): LoginAttempt { - return static::recordAttempt(false, $ipAddress, $user); + return static::recordAttempt(false, $ipAddress, $user, $userAgent); } - public static function recordAttempt(bool $success, IPAddress $ipAddress, ?User $user = null): LoginAttempt - { + public static function recordAttempt( + bool $success, + IPAddress $ipAddress, + ?User $user = null, + ?string $userAgent = null + ): LoginAttempt { $attempt = new static; $attempt->was_successful = $success; $attempt->attempt_ip = $ipAddress; + $attempt->user_agent = $userAgent ?? ''; if ($user !== null) { $attempt->user_id = $user; diff --git a/src/Users/Session.php b/src/Users/Session.php index 725b84c9..67f8ce9d 100644 --- a/src/Users/Session.php +++ b/src/Users/Session.php @@ -49,6 +49,7 @@ class Session extends Model public function setSessionIpAttribute(IPAddress $ipAddress): void { $this->attributes['session_ip'] = $ipAddress->getRaw(); + $this->attributes['session_country'] = $ipAddress->getCountryCode(); } public function user() diff --git a/src/Users/User.php b/src/Users/User.php index 6add7b51..021abbea 100644 --- a/src/Users/User.php +++ b/src/Users/User.php @@ -40,7 +40,7 @@ class User extends Model return $user; } - public static function validateUsername(string $username): string + public static function validateUsername(string $username, bool $checkInUse = false): string { $username_length = strlen($username); @@ -68,6 +68,36 @@ class User extends Model return 'spacing'; } + if ($checkInUse && static::where('username', $username)->count() > 0) { + return 'in-use'; + } + + return ''; + } + + public static function validateEmail(string $email, bool $checkInUse = false): string + { + if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + return 'format'; + } + + if (!check_mx_record($email)) { + return 'dns'; + } + + if ($checkInUse && static::where('email', $email)->count() > 0) { + return 'in-use'; + } + + return ''; + } + + public static function validatePassword(string $password): string + { + if (password_entropy($password) < 32) { + return 'weak'; + } + return ''; } @@ -97,7 +127,7 @@ class User extends Model ->count() > 0; } - public function validatePassword(string $password): bool + public function verifyPassword(string $password): bool { if (password_verify($password, $this->password) !== true) { return false; diff --git a/views/mio/macros.twig b/views/mio/macros.twig index 7c0e6c0b..c16e2049 100644 --- a/views/mio/macros.twig +++ b/views/mio/macros.twig @@ -4,13 +4,14 @@ {% endspaceless %} {% endmacro %} -{% macro navigation(links, current, top) %} +{% macro navigation(links, current, top, fmt) %} {% set top = top|default(false) == true %} {% set current = current|default(null) %} + {% set fmt = fmt|default('%s') %} {% endmacro %} diff --git a/views/mio/master.twig b/views/mio/master.twig index 49ea65a0..c171544a 100644 --- a/views/mio/master.twig +++ b/views/mio/master.twig @@ -32,7 +32,8 @@ diff --git a/views/mio/settings/account.twig b/views/mio/settings/account.twig new file mode 100644 index 00000000..4e1a68bb --- /dev/null +++ b/views/mio/settings/account.twig @@ -0,0 +1,171 @@ +{% extends '@mio/settings/master.twig' %} + +{% block settings_content %} +
+
+ + + +
+ +
+ + +
+
+{% endblock %} diff --git a/views/mio/settings/avatar.twig b/views/mio/settings/avatar.twig new file mode 100644 index 00000000..d9a566ba --- /dev/null +++ b/views/mio/settings/avatar.twig @@ -0,0 +1,5 @@ +{% extends '@mio/settings/master.twig' %} + +{% block settings_content %} +

Sorry for getting your hopes up with the button, but not yet! Read the front page while logged in.

+{% endblock %} diff --git a/views/mio/settings/login-history.twig b/views/mio/settings/login-history.twig new file mode 100644 index 00000000..8a3459e5 --- /dev/null +++ b/views/mio/settings/login-history.twig @@ -0,0 +1,7 @@ +{% extends '@mio/settings/master.twig' %} + +{% block settings_content %} + {% for login_attempt in user_login_attempts %} +

{{ login_attempt.attempt_id }}, {{ login_attempt.was_successful }}, {{ login_attempt.attempt_ip.string }}, {{ login_attempt.user_agent }}, {{ login_attempt.attempt_country }}, {{ login_attempt.created_at }}

+ {% endfor %} +{% endblock %} diff --git a/views/mio/settings/master.twig b/views/mio/settings/master.twig new file mode 100644 index 00000000..79a4298e --- /dev/null +++ b/views/mio/settings/master.twig @@ -0,0 +1,34 @@ +{% extends '@mio/master.twig' %} +{% from '@mio/macros.twig' import navigation %} + +{% set title = 'Settings ยป ' ~ settings_title %} + +{% block content %} + {{ navigation(settings_modes|flip, settings_mode, true, '?m=%s') }} + + {% block settings_container %} + {% if settings_errors is defined and settings_errors|length > 0 %} +
+
Information
+
+ +
+
+ {% endif %} + +
+
{{ title }}
+
+ {% block settings_content %} + This is a blank settings page. + {% endblock %} +
+
+ {% endblock %} + + {{ navigation(mio_navigation) }} +{% endblock %} diff --git a/views/mio/settings/notfound.twig b/views/mio/settings/notfound.twig new file mode 100644 index 00000000..44840fd8 --- /dev/null +++ b/views/mio/settings/notfound.twig @@ -0,0 +1,5 @@ +{% extends '@mio/settings/master.twig' %} + +{% block settings_content %} +

Could not find what you were looking for.

+{% endblock %} diff --git a/views/mio/settings/sessions.twig b/views/mio/settings/sessions.twig new file mode 100644 index 00000000..3242e1bb --- /dev/null +++ b/views/mio/settings/sessions.twig @@ -0,0 +1,7 @@ +{% extends '@mio/settings/master.twig' %} + +{% block settings_content %} + {% for session in user_sessions %} +

{{ session.session_id }}, {{ session.session_ip.string }}, {{ session.user_agent|default('-') }}, {{ session.session_country }}, {{ session.created_at }}, {{ session.expires_on }}

+ {% endfor %} +{% endblock %}