Merged in feature/MAPG-69-implement-google-registration (pull request #112)

Feature/MAPG-69 implement google registration
This commit is contained in:
Bence Pőcze 2020-06-14 18:45:25 +00:00
commit afbd0b4e20
33 changed files with 769 additions and 85 deletions

View File

@ -8,3 +8,6 @@ GOOGLE_MAPS_JS_API_KEY=your_google_maps_js_api_key
LEAFLET_TILESERVER_URL=a_leaflet_compatible_tileserver_url LEAFLET_TILESERVER_URL=a_leaflet_compatible_tileserver_url
LEAFLET_TILESERVER_ATTRIBUTION=attribution_to_be_shown_for_tiles LEAFLET_TILESERVER_ATTRIBUTION=attribution_to_be_shown_for_tiles
STATIC_ROOT=/static STATIC_ROOT=/static
MAIL_FROM=mapguesser@mapguesser-dev.ch
MAIL_HOST=mail
MAIL_PORT=2500

View File

@ -5,7 +5,8 @@
"license": "GNU GPL 3.0", "license": "GNU GPL 3.0",
"require": { "require": {
"vlucas/phpdotenv": "^4.1", "vlucas/phpdotenv": "^4.1",
"symfony/console": "^5.1" "symfony/console": "^5.1",
"phpmailer/phpmailer": "^6.1"
}, },
"require-dev": {}, "require-dev": {},
"autoload": { "autoload": {

70
composer.lock generated
View File

@ -4,8 +4,76 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "13a0eaff2786786caff2be86ac704fc7", "content-hash": "67a75c3149ef859545476427e7f2f686",
"packages": [ "packages": [
{
"name": "phpmailer/phpmailer",
"version": "v6.1.6",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "c2796cb1cb99d7717290b48c4e6f32cb6c60b7b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/c2796cb1cb99d7717290b48c4e6f32cb6c60b7b3",
"reference": "c2796cb1cb99d7717290b48c4e6f32cb6c60b7b3",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-filter": "*",
"php": ">=5.5.0"
},
"require-dev": {
"doctrine/annotations": "^1.2",
"friendsofphp/php-cs-fixer": "^2.2",
"phpunit/phpunit": "^4.8 || ^5.7"
},
"suggest": {
"ext-mbstring": "Needed to send email in multibyte encoding charset",
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
"psr/log": "For optional PSR-3 debug logging",
"stevenmaguire/oauth2-microsoft": "Needed for Microsoft XOAUTH2 authentication",
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPMailer\\PHPMailer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-only"
],
"authors": [
{
"name": "Marcus Bointon",
"email": "phpmailer@synchromedia.co.uk"
},
{
"name": "Jim Jagielski",
"email": "jimjag@gmail.com"
},
{
"name": "Andy Prevost",
"email": "codeworxtech@users.sourceforge.net"
},
{
"name": "Brent R. Matzelle"
}
],
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"funding": [
{
"url": "https://github.com/synchro",
"type": "github"
}
],
"time": "2020-05-27T12:24:03+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.7.3", "version": "1.7.3",

View File

@ -10,8 +10,6 @@ $select->columns(['id', 'bound_south_lat', 'bound_west_lng', 'bound_north_lat',
$result = $select->execute(); $result = $select->execute();
\Container::$dbConnection->startTransaction();
while ($map = $result->fetch(IResultSet::FETCH_ASSOC)) { while ($map = $result->fetch(IResultSet::FETCH_ASSOC)) {
$bounds = Bounds::createDirectly($map['bound_south_lat'], $map['bound_west_lng'], $map['bound_north_lat'], $map['bound_east_lng']); $bounds = Bounds::createDirectly($map['bound_south_lat'], $map['bound_west_lng'], $map['bound_north_lat'], $map['bound_east_lng']);
@ -20,5 +18,3 @@ while ($map = $result->fetch(IResultSet::FETCH_ASSOC)) {
$modify->set('area', $bounds->calculateApproximateArea()); $modify->set('area', $bounds->calculateApproximateArea());
$modify->save(); $modify->save();
} }
\Container::$dbConnection->commit();

View File

@ -0,0 +1,17 @@
<?php
use MapGuesser\Database\Query\Modify;
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet;
$select = new Select(\Container::$dbConnection, 'users');
$select->columns(['id']);
$result = $select->execute();
while ($map = $result->fetch(IResultSet::FETCH_ASSOC)) {
$modify = new Modify(\Container::$dbConnection, 'users');
$modify->setId($map['id']);
$modify->set('active', true);
$modify->save();
}

View File

@ -0,0 +1,14 @@
CREATE TABLE `user_confirmations` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`token` varchar(64) NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `token` (`token`),
CONSTRAINT `user_confirmations_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
ALTER TABLE
`users`
ADD
`active` tinyint(1) NOT NULL DEFAULT 0;

View File

@ -8,6 +8,7 @@ services:
- .:/var/www/mapguesser - .:/var/www/mapguesser
links: links:
- 'mariadb' - 'mariadb'
- 'mail'
mariadb: mariadb:
image: mariadb:10.1 image: mariadb:10.1
volumes: volumes:
@ -17,5 +18,10 @@ services:
MYSQL_DATABASE: 'mapguesser' MYSQL_DATABASE: 'mapguesser'
MYSQL_USER: 'mapguesser' MYSQL_USER: 'mapguesser'
MYSQL_PASSWORD: 'mapguesser' MYSQL_PASSWORD: 'mapguesser'
mail:
image: marcopas/docker-mailslurper:latest
ports:
- 8080:8080
- 8085:8085
volumes: volumes:
mysql: mysql:

13
mail/signup.tpl Normal file
View File

@ -0,0 +1,13 @@
Hi,
You recently signed up on MapGuesser with this email address ({{EMAIL}}). To activate your account, please click on the following link:
<a href="{{ACTIVATE_LINK}}" title="Account activation">{{ACTIVATE_LINK}}</a>
If you did not sign up on MapGuesser or changed your mind, no further action is required, your email address will be deleted soon.
However if you want to immediately delete it, please click on the following link:
<a href="{{CANCEL_LINK}}" title="Sign up cancellation">{{CANCEL_LINK}}</a>
Have fun on MapGuesser!
Regards,
MapGuesser

View File

@ -15,6 +15,7 @@ class Container
static MapGuesser\Interfaces\Database\IConnection $dbConnection; static MapGuesser\Interfaces\Database\IConnection $dbConnection;
static MapGuesser\Routing\RouteCollection $routeCollection; static MapGuesser\Routing\RouteCollection $routeCollection;
static \SessionHandlerInterface $sessionHandler; static \SessionHandlerInterface $sessionHandler;
static MapGuesser\Interfaces\Request\IRequest $request;
} }
Container::$dbConnection = new MapGuesser\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']); Container::$dbConnection = new MapGuesser\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']);

View File

@ -2,7 +2,6 @@
require '../web.php'; require '../web.php';
$host = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'];
$method = strtolower($_SERVER['REQUEST_METHOD']); $method = strtolower($_SERVER['REQUEST_METHOD']);
$url = substr($_SERVER['REQUEST_URI'], strlen('/')); $url = substr($_SERVER['REQUEST_URI'], strlen('/'));
if (($pos = strpos($url, '?')) !== false) { if (($pos = strpos($url, '?')) !== false) {
@ -15,10 +14,10 @@ $match = Container::$routeCollection->match($method, explode('/', $url));
if ($match !== null) { if ($match !== null) {
list($route, $params) = $match; list($route, $params) = $match;
$request = new MapGuesser\Request\Request($_GET, $params, $_POST, $_SESSION); Container::$request->setParsedRouteParams($params);
$handler = $route->getHandler(); $handler = $route->getHandler();
$controller = new $handler[0]($request); $controller = new $handler[0](Container::$request);
if ($controller instanceof MapGuesser\Interfaces\Authorization\ISecured) { if ($controller instanceof MapGuesser\Interfaces\Authorization\ISecured) {
$authorized = $controller->authorize(); $authorized = $controller->authorize();
@ -26,7 +25,7 @@ if ($match !== null) {
$authorized = true; $authorized = true;
} }
if ($method === 'post' && $request->post('anti_csrf_token') !== $request->session()->get('anti_csrf_token')) { if ($method === 'post' && Container::$request->post('anti_csrf_token') !== Container::$request->session()->get('anti_csrf_token')) {
header('Content-Type: text/html; charset=UTF-8', true, 403); header('Content-Type: text/html; charset=UTF-8', true, 403);
echo json_encode(['error' => 'no_valid_anti_csrf_token']); echo json_encode(['error' => 'no_valid_anti_csrf_token']);
return; return;
@ -41,7 +40,7 @@ if ($match !== null) {
return; return;
} elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) { } elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) {
header('Location: ' . $host . '/' . $response->getUrl(), true, $response->getHttpCode()); header('Location: ' . Container::$request->getBase() . '/' . $response->getUrl(), true, $response->getHttpCode());
return; return;
} }

View File

@ -69,8 +69,9 @@ sub {
bottom: -0.4em; bottom: -0.4em;
} }
.mono { hr {
font-family: 'Roboto Mono', monospace; border: solid #bbbbbb 1px;
margin: 10px 0;
} }
.bold { .bold {
@ -261,13 +262,18 @@ div.modal {
visibility: hidden; visibility: hidden;
} }
p.formError { p.error, p.formError {
color: #7f2929; color: #7f2929;
font-weight: 500; font-weight: 500;
}
p.formError {
display: none; display: none;
} }
div.header { div.header {
display: grid;
grid-template-columns: auto auto;
background-color: #333333; background-color: #333333;
height: 50px; height: 50px;
line-height: 50px; line-height: 50px;
@ -275,30 +281,25 @@ div.header {
color: white; color: white;
} }
div.header>div.grid {
display: grid;
grid-template-columns: auto auto;
}
div.header.small { div.header.small {
height: 40px; height: 40px;
line-height: 40px; line-height: 40px;
} }
div.header>div.grid>:nth-child(2) { div.header>p.header {
line-height: inherit; line-height: inherit;
text-align: right; text-align: right;
} }
div.header>div.grid>:nth-child(2)>span { div.header>p.header>span {
padding-left: 6px; padding-left: 6px;
} }
div.header>div.grid>:nth-child(2)>span>a:link, div.header>div.grid>:nth-child(2)>span>a:visited { div.header>p.header>span>a:link, div.header>p.header>span>a:visited {
color: inherit; color: inherit;
} }
div.header>div.grid>:nth-child(2)>span:not(:last-child) { div.header>p.header>span:not(:last-child) {
border-right: solid white 1px; border-right: solid white 1px;
padding-right: 6px; padding-right: 6px;
} }
@ -337,7 +338,7 @@ div.box {
} }
@media screen and (max-width: 599px) { @media screen and (max-width: 599px) {
div.header.small h1 span { div.header h1 span {
display: none; display: none;
} }
button, a.button { button, a.button {

View File

@ -13,7 +13,10 @@
var errorText; var errorText;
switch (this.response.error) { switch (this.response.error) {
case 'user_not_found': case 'user_not_found':
errorText = 'No user found with the given email address.'; errorText = 'No user found with the given email address. You can <a href="/signup" title="Sign up">sign up here</a>!';
break;
case 'user_not_active':
errorText = 'User found with the given email address, but the account is not activated. Please check your email and click on the activation link!';
break; break;
case 'password_not_match': case 'password_not_match':
errorText = 'The given password is wrong.' errorText = 'The given password is wrong.'

View File

@ -72,7 +72,7 @@ var MapGuesser = {
closeButton.classList.add('gray'); closeButton.classList.add('gray');
closeButton.classList.add('marginTop'); closeButton.classList.add('marginTop');
closeButton.textContent = 'Cancel'; closeButton.textContent = 'Close';
closeButton.onclick = function () { closeButton.onclick = function () {
MapGuesser.hideModal(); MapGuesser.hideModal();
}; };
@ -88,6 +88,14 @@ var MapGuesser = {
} }
document.getElementById('cover').style.visibility = 'hidden'; document.getElementById('cover').style.visibility = 'hidden';
},
toggleDisableOnChange: function (input, button) {
if (input.defaultValue !== input.value) {
button.disabled = false;
} else {
button.disabled = true;
}
} }
}; };

View File

@ -0,0 +1,51 @@
(function () {
var form = document.getElementById('profileForm');
form.elements.password_new.onkeyup = function () {
MapGuesser.toggleDisableOnChange(this, form.elements.save);
};
form.elements.password_new_confirm.onkeyup = function () {
MapGuesser.toggleDisableOnChange(this, form.elements.save);
};
form.onsubmit = function (e) {
document.getElementById('loading').style.visibility = 'visible';
e.preventDefault();
var formData = new FormData(form);
MapGuesser.httpRequest('POST', form.action, function () {
document.getElementById('loading').style.visibility = 'hidden';
if (this.response.error) {
var errorText;
switch (this.response.error) {
case 'password_not_match':
errorText = 'The given current password is wrong.'
break;
case 'passwords_too_short':
errorText = 'The given new password is too short. Please choose a password that is at least 6 characters long!'
break;
case 'passwords_not_match':
errorText = 'The given new passwords do not match.'
break;
}
var profileFormError = document.getElementById('profileFormError');
profileFormError.style.display = 'block';
profileFormError.innerHTML = errorText;
form.elements.password_new.select();
return;
}
document.getElementById('profileFormError').style.display = 'none';
form.reset();
form.elements.save.disabled = true;
form.elements.password_new.focus();
}, formData);
};
})();

View File

@ -0,0 +1,47 @@
(function () {
var form = document.getElementById('signupForm');
form.onsubmit = function (e) {
document.getElementById('loading').style.visibility = 'visible';
e.preventDefault();
var formData = new FormData(form);
MapGuesser.httpRequest('POST', form.action, function () {
document.getElementById('loading').style.visibility = 'hidden';
if (this.response.error) {
var errorText;
switch (this.response.error) {
case 'passwords_too_short':
errorText = 'The given password is too short. Please choose a password that is at least 6 characters long!'
break;
case 'passwords_not_match':
errorText = 'The given passwords do not match.'
break;
case 'user_found':
errorText = 'There is a user already registered with the given email address. Please <a href="/login" title="Login">login here</a>!';
break;
case 'not_active_user_found':
errorText = 'There is a user already registered with the given email address. Please check your email and click on the activation link!';
break;
}
var signupFormError = document.getElementById('signupFormError');
signupFormError.style.display = 'block';
signupFormError.innerHTML = errorText;
form.elements.email.select();
return;
}
document.getElementById('signupFormError').style.display = 'none';
form.reset();
form.elements.email.focus();
MapGuesser.showModalWithContent('Sign up successful', 'Sign up was successful. Please check your email and click on the activation link to activate your account!');
}, formData);
};
})();

View File

@ -53,6 +53,11 @@ class LoginController
$user = new User($userData); $user = new User($userData);
if (!$user->getActive()) {
$data = ['error' => 'user_not_active'];
return new JsonContent($data);
}
if (!$user->checkPassword($this->request->post('password'))) { if (!$user->checkPassword($this->request->post('password'))) {
$data = ['error' => 'password_not_match']; $data = ['error' => 'password_not_match'];
return new JsonContent($data); return new JsonContent($data);
@ -68,6 +73,6 @@ class LoginController
{ {
$this->request->session()->delete('user'); $this->request->session()->delete('user');
return new Redirect([\Container::$routeCollection->getRoute('login'), []], IRedirect::TEMPORARY); return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY);
} }
} }

View File

@ -0,0 +1,192 @@
<?php namespace MapGuesser\Controller;
use MapGuesser\Database\Query\Modify;
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Interfaces\Response\IRedirect;
use MapGuesser\Mailing\Mail;
use MapGuesser\Model\User;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
use MapGuesser\Response\Redirect;
class SignupController
{
private IRequest $request;
public function __construct(IRequest $request)
{
$this->request = $request;
}
public function getSignupForm()
{
$session = $this->request->session();
if ($session->get('user')) {
return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY);
}
$data = [];
return new HtmlContent('signup/signup', $data);
}
public function signup(): IContent
{
$session = $this->request->session();
if ($session->get('user')) {
//TODO: return with some error
$data = ['success' => true];
return new JsonContent($data);
}
$select = new Select(\Container::$dbConnection, 'users');
$select->columns(User::getFields());
$select->where('email', '=', $this->request->post('email'));
$userData = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
if ($userData !== null) {
$user = new User($userData);
if ($user->getActive()) {
$data = ['error' => 'user_found'];
} else {
$data = ['error' => 'not_active_user_found'];
}
return new JsonContent($data);
}
if (strlen($this->request->post('password')) < 6) {
$data = ['error' => 'passwords_too_short'];
return new JsonContent($data);
}
if ($this->request->post('password') !== $this->request->post('password_confirm')) {
$data = ['error' => 'passwords_not_match'];
return new JsonContent($data);
}
$user = new User([
'email' => $this->request->post('email'),
]);
$user->setPlainPassword($this->request->post('password'));
\Container::$dbConnection->startTransaction();
$modify = new Modify(\Container::$dbConnection, 'users');
$modify->fill($user->toArray());
$modify->save();
$userId = $modify->getId();
$token = hash('sha256', serialize($user) . random_bytes(10) . microtime());
$modify = new Modify(\Container::$dbConnection, 'user_confirmations');
$modify->set('user_id', $userId);
$modify->set('token', $token);
$modify->save();
\Container::$dbConnection->commit();
$this->sendConfirmationEmail($user->getEmail(), $token);
$data = ['success' => true];
return new JsonContent($data);
}
public function activate()
{
$session = $this->request->session();
if ($session->get('user')) {
return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY);
}
$select = new Select(\Container::$dbConnection, 'user_confirmations');
$select->columns(['id', 'user_id']);
$select->where('token', '=', $this->request->query('token'));
$confirmation = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
if ($confirmation === null) {
$data = [];
return new HtmlContent('signup/activate', $data);
}
\Container::$dbConnection->startTransaction();
$modify = new Modify(\Container::$dbConnection, 'user_confirmations');
$modify->setId($confirmation['id']);
$modify->delete();
$modify = new Modify(\Container::$dbConnection, 'users');
$modify->setId($confirmation['user_id']);
$modify->set('active', true);
$modify->save();
\Container::$dbConnection->commit();
$select = new Select(\Container::$dbConnection, 'users');
$select->columns(User::getFields());
$select->whereId($confirmation['user_id']);
$userData = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
$user = new User($userData);
$session->set('user', $user);
return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY);
}
public function cancel()
{
$session = $this->request->session();
if ($session->get('user')) {
return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY);
}
$select = new Select(\Container::$dbConnection, 'user_confirmations');
$select->columns(['id', 'user_id']);
$select->where('token', '=', $this->request->query('token'));
$confirmation = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
if ($confirmation === null) {
$data = ['success' => false];
return new HtmlContent('signup/cancel', $data);
}
\Container::$dbConnection->startTransaction();
$modify = new Modify(\Container::$dbConnection, 'user_confirmations');
$modify->setId($confirmation['id']);
$modify->delete();
$modify = new Modify(\Container::$dbConnection, 'users');
$modify->setId($confirmation['user_id']);
$modify->delete();
\Container::$dbConnection->commit();
$data = ['success' => true];
return new HtmlContent('signup/cancel', $data);
}
private function sendConfirmationEmail($email, $token): void
{
$mail = new Mail();
$mail->addRecipient($email);
$mail->setSubject('Welcome to MapGuesser - Activate your account');
$mail->setBodyFromTemplate('signup', [
'EMAIL' => $email,
'ACTIVATE_LINK' => $this->request->getBase() . '/signup/activate/' . $token,
'CANCEL_LINK' => $this->request->getBase() . '/signup/cancel/' . $token,
]);
$mail->send();
}
}

View File

@ -0,0 +1,56 @@
<?php namespace MapGuesser\Controller;
use MapGuesser\Database\Query\Modify;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
class UserController
{
private IRequest $request;
public function __construct(IRequest $request)
{
$this->request = $request;
}
public function getProfile(): IContent
{
$user = $this->request->user();
$data = ['user' => $user->toArray()];
return new HtmlContent('profile', $data);
}
public function saveProfile(): IContent
{
$user = $this->request->user();
if (!$user->checkPassword($this->request->post('password'))) {
$data = ['error' => 'password_not_match'];
return new JsonContent($data);
}
if (strlen($this->request->post('password_new')) > 0) {
if (strlen($this->request->post('password_new')) < 6) {
$data = ['error' => 'passwords_too_short'];
return new JsonContent($data);
}
if ($this->request->post('password_new') !== $this->request->post('password_new_confirm')) {
$data = ['error' => 'passwords_not_match'];
return new JsonContent($data);
}
$user->setPlainPassword($this->request->post('password_new'));
}
$modify = new Modify(\Container::$dbConnection, 'users');
$modify->fill($user->toArray());
$modify->save();
$data = ['success' => true];
return new JsonContent($data);
}
}

View File

@ -7,4 +7,6 @@ interface IUser
const PERMISSION_ADMIN = 1; const PERMISSION_ADMIN = 1;
public function hasPermission(int $permission): bool; public function hasPermission(int $permission): bool;
public function getDisplayName(): string;
} }

View File

@ -4,6 +4,10 @@ use MapGuesser\Interfaces\Authentication\IUser;
interface IRequest interface IRequest
{ {
public function setParsedRouteParams(array &$routeParams);
public function getBase(): string;
public function query(string $key); public function query(string $key);
public function post(string $key); public function post(string $key);

81
src/Mailing/Mail.php Normal file
View File

@ -0,0 +1,81 @@
<?php namespace MapGuesser\Mailing;
use PHPMailer\PHPMailer\PHPMailer;
class Mail
{
private array $recipients = [];
public string $subject = '';
public string $body = '';
public function addRecipient(string $mail, ?string $name = null): void
{
$this->recipients[] = [$mail, $name];
}
public function setSubject(string $subject): void
{
$this->subject = $subject;
}
public function setBody(string $body): void
{
$this->body = $body;
}
public function setBodyFromTemplate(string $template, array $params = []): void
{
$this->body = file_get_contents(ROOT . '/mail/' . $template . '.tpl');
foreach ($params as $key => $param) {
$this->body = str_replace('{{' . $key . '}}', $param, $this->body);
}
}
public function send(): void
{
$mailer = new PHPMailer(true);
$mailer->CharSet = 'utf-8';
$mailer->Hostname = substr($_ENV['MAIL_FROM'], strpos($_ENV['MAIL_FROM'], '@') + 1);
if (!empty($_ENV['MAIL_HOST'])) {
$mailer->Mailer = 'smtp';
$mailer->Host = $_ENV['MAIL_HOST'];
$mailer->Port = !empty($_ENV['MAIL_PORT']) ? $_ENV['MAIL_PORT'] : 25;
$mailer->SMTPSecure = !empty($_ENV['MAIL_SECURE']) ? $_ENV['MAIL_SECURE'] : '';
if (!empty($_ENV['MAIL_USER'])) {
$mailer->SMTPAuth = true;
$mailer->Username = $_ENV['MAIL_USER'];
$mailer->Password = $_ENV['MAIL_PASSWORD'];
} else {
$mailer->SMTPAuth = false;
}
} else {
$mailer->Mailer = 'mail';
}
$mailer->setFrom($_ENV['MAIL_FROM'], 'MapGuesser');
$mailer->addReplyTo($_ENV['MAIL_FROM'], 'MapGuesser');
$mailer->Sender = !empty($_ENV['MAIL_BOUNCE']) ? $_ENV['MAIL_BOUNCE'] : $_ENV['MAIL_FROM'];
$mailer->Subject = $this->subject;
$mailer->Body = $this->body;
foreach ($this->recipients as $recipient) {
$this->sendMail($mailer, $recipient);
}
}
private function sendMail(PHPMailer $mailer, array $recipient)
{
$mailer->clearAddresses();
$mailer->addAddress($recipient[0], $recipient[1]);
$mailer->send();
}
}

View File

@ -6,7 +6,7 @@ class User extends BaseModel implements IUser
{ {
private static array $types = ['user', 'admin']; private static array $types = ['user', 'admin'];
protected static array $fields = ['email', 'password', 'type']; protected static array $fields = ['email', 'password', 'type', 'active'];
private string $email; private string $email;
@ -14,6 +14,8 @@ class User extends BaseModel implements IUser
private string $type = 'user'; private string $type = 'user';
private bool $active = false;
public function setEmail(string $email): void public function setEmail(string $email): void
{ {
$this->email = $email; $this->email = $email;
@ -36,6 +38,11 @@ class User extends BaseModel implements IUser
} }
} }
public function setActive($active): void
{
$this->active = (bool) $active;
}
public function getEmail(): string public function getEmail(): string
{ {
return $this->email; return $this->email;
@ -51,6 +58,11 @@ class User extends BaseModel implements IUser
return $this->type; return $this->type;
} }
public function getActive(): bool
{
return $this->active;
}
public function hasPermission(int $permission): bool public function hasPermission(int $permission): bool
{ {
switch ($permission) { switch ($permission) {
@ -63,6 +75,11 @@ class User extends BaseModel implements IUser
} }
} }
public function getDisplayName(): string
{
return $this->email;
}
public function checkPassword(string $password): bool public function checkPassword(string $password): bool
{ {
return password_verify($password, $this->password); return password_verify($password, $this->password);

View File

@ -3,26 +3,37 @@
use MapGuesser\Interfaces\Authentication\IUser; use MapGuesser\Interfaces\Authentication\IUser;
use MapGuesser\Interfaces\Request\IRequest; use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Request\ISession; use MapGuesser\Interfaces\Request\ISession;
use MapGuesser\Model\User;
class Request implements IRequest class Request implements IRequest
{ {
private string $base;
private array $get; private array $get;
private array $routeParams; private array $routeParams = [];
private array $post; private array $post;
private Session $session; private Session $session;
public function __construct(array &$get, array &$routeParams, array &$post, array &$session) public function __construct(string $base, array &$get, array &$post, array &$session)
{ {
$this->base = $base;
$this->get = &$get; $this->get = &$get;
$this->routeParams = &$routeParams;
$this->post = &$post; $this->post = &$post;
$this->session = new Session($session); $this->session = new Session($session);
} }
public function setParsedRouteParams(array &$routeParams)
{
$this->routeParams = &$routeParams;
}
public function getBase(): string
{
return $this->base;
}
public function query($key) public function query($key)
{ {
if (isset($this->get[$key])) { if (isset($this->get[$key])) {

View File

@ -14,41 +14,39 @@ $jsFiles = [
?> ?>
<?php require ROOT . '/views/templates/main_header.php'; ?> <?php require ROOT . '/views/templates/main_header.php'; ?>
<div class="header small"> <div class="header small">
<div class="grid"> <h1>
<h1> <a href="/maps" title="Back to playable maps">
<a href="/maps" title="Back to playable maps"> <img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>">
<img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>"> <span>MapGuesser</span>
<span>MapGuesser</span> </a>
</a> </h1>
</h1> <p class="header">
<p> <span><a href="javascript:;" id="mapName" title="Edit map data"><?= $mapName ?></a></span><!--
<span class="bold"><a href="javascript:;" id="mapName" title="Edit map data"><?= $mapName ?></a></span><!-- --><span><!--
--><span><!-- <?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?>
<?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?> --><svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
--><svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" d="M8 3.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H4a.5.5 0 0 1 0-1h3.5V4a.5.5 0 0 1 .5-.5z"/>
<path fill-rule="evenodd" d="M8 3.5a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H4a.5.5 0 0 1 0-1h3.5V4a.5.5 0 0 1 .5-.5z"/> <path fill-rule="evenodd" d="M7.5 8a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1H8.5V12a.5.5 0 0 1-1 0V8z"/>
<path fill-rule="evenodd" d="M7.5 8a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1H8.5V12a.5.5 0 0 1-1 0V8z"/> <path fill-rule="evenodd" d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
<path fill-rule="evenodd" d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/> </svg>
</svg> <span id="added" class="bold">0</span><!--
<span id="added" class="bold">0</span><!-- --></span><!--
--></span><!-- --><span><!--
--><span><!-- <?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?>
<?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?> --><svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
--><svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <path d="M15.502 1.94a.5.5 0 0 1 0 .706a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456l-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/> <path fill-rule="evenodd" d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
<path d="M15.502 1.94a.5.5 0 0 1 0 .706a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456l-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/> <path fill-rule="evenodd" d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/> </svg>
</svg> <span id="edited" class="bold">0</span><!--
<span id="edited" class="bold">0</span><!-- --></span><!--
--></span><!-- --><span><!--
--><span><!-- <?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?>
<?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?> --><svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
--><svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" d="M3.5 8a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.5-.5z"/>
<path fill-rule="evenodd" d="M3.5 8a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.5-.5z"/> <path fill-rule="evenodd" d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/>
<path fill-rule="evenodd" d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/> </svg>
</svg> <span id="deleted" class="bold">0</span><!--
<span id="deleted" class="bold">0</span><!-- --></span>
--></span> </p>
</p>
</div>
</div> </div>
<div id="metadata" class="modal"> <div id="metadata" class="modal">
<h2>Edit map data</h2> <h2>Edit map data</h2>

View File

@ -9,19 +9,17 @@ $jsFiles = [
?> ?>
<?php require ROOT . '/views/templates/main_header.php'; ?> <?php require ROOT . '/views/templates/main_header.php'; ?>
<div class="header small"> <div class="header small">
<div class="grid"> <h1>
<h1> <a href="/maps" title="Back to playable maps">
<a href="/maps" title="Back to playable maps"> <img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>">
<img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>"> <span>MapGuesser</span>
<span>MapGuesser</span> </a>
</a> </h1>
</h1> <p class="header">
<p> <span id="mapName" class="bold"><?= $mapName ?></span><!--
<span id="mapName" class="bold"><?= $mapName ?></span><!-- --><span>Round <span id="currentRound" class="bold"></span></span><!--
--><span>Round <span id="currentRound" class="bold"></span></span><!-- --><span>Score <span id="currentScoreSum" class="bold"></span></span>
--><span>Score <span id="currentScoreSum" class="bold"></span></span> </p>
</p>
</div>
</div> </div>
<div id="guessCover"></div> <div id="guessCover"></div>
<div id="panorama"></div> <div id="panorama"></div>

View File

@ -11,7 +11,7 @@ $jsFiles = [
<form id="loginForm" action="/login" method="post"> <form id="loginForm" action="/login" method="post">
<input class="big fullWidth" type="email" name="email" placeholder="Email address" autofocus> <input class="big fullWidth" type="email" name="email" placeholder="Email address" autofocus>
<input class="big fullWidth marginTop" type="password" name="password" placeholder="Password"> <input class="big fullWidth marginTop" type="password" name="password" placeholder="Password">
<p id="loginFormError" class="formError marginTop"></p> <p id="loginFormError" class="formError justify marginTop"></p>
<div class="right marginTop"> <div class="right marginTop">
<button type="submit">Login</button> <button type="submit">Login</button>
</div> </div>

25
views/profile.php Normal file
View File

@ -0,0 +1,25 @@
<?php
$jsFiles = [
'js/profile.js',
];
?>
<?php require ROOT . '/views/templates/main_header.php'; ?>
<?php require ROOT . '/views/templates/header.php'; ?>
<div class="main">
<h2>Profile</h2>
<div class="box">
<form id="profileForm" action="/profile" method="post">
<?php /* TODO: disabled for the time being, email modification should be implemented */ ?>
<input class="big fullWidth" type="email" name="email" placeholder="Email address" value="<?= $user['email'] ?>" disabled>
<input class="big fullWidth marginTop" type="password" name="password_new" placeholder="New password" autofocus>
<input class="big fullWidth marginTop" type="password" name="password_new_confirm" placeholder="New password confirmation">
<hr>
<input class="big fullWidth" type="password" name="password" placeholder="Current password">
<p id="profileFormError" class="formError justify marginTop"></p>
<div class="right marginTop">
<button type="submit" name="save" disabled>Save</button>
</div>
</form>
</div>
</div>
<?php require ROOT . '/views/templates/main_footer.php'; ?>

View File

@ -0,0 +1,9 @@
<?php require ROOT . '/views/templates/main_header.php'; ?>
<?php require ROOT . '/views/templates/header.php'; ?>
<div class="main">
<h2>Account activation</h2>
<div class="box">
<p class="error justify">Activation failed. Please check the link you entered or retry <a href="/signup" title="Sign up">sign up</a>!</p>
</div>
</div>
<?php require ROOT . '/views/templates/main_footer.php'; ?>

13
views/signup/cancel.php Normal file
View File

@ -0,0 +1,13 @@
<?php require ROOT . '/views/templates/main_header.php'; ?>
<?php require ROOT . '/views/templates/header.php'; ?>
<div class="main">
<h2>Account cancellation</h2>
<div class="box">
<?php if ($success) : ?>
<p class="justify">Cancellation was successfull. You can <a href="/signup" title="Sign up">sign up</a> any time if you want!</p>
<?php else: ?>
<p class="error justify">Cancellation failed. Please check the link you entered! Maybe the account was already deleted, in this case no further action is required.</p>
<?php endif; ?>
</div>
</div>
<?php require ROOT . '/views/templates/main_footer.php'; ?>

22
views/signup/signup.php Normal file
View File

@ -0,0 +1,22 @@
<?php
$jsFiles = [
'js/signup.js',
];
?>
<?php require ROOT . '/views/templates/main_header.php'; ?>
<?php require ROOT . '/views/templates/header.php'; ?>
<div class="main">
<h2>Sign up</h2>
<div class="box">
<form id="signupForm" action="/signup" method="post">
<input class="big fullWidth" type="email" name="email" placeholder="Email address" autofocus>
<input class="big fullWidth marginTop" type="password" name="password" placeholder="Password">
<input class="big fullWidth marginTop" type="password" name="password_confirm" placeholder="Password confirmation">
<p id="signupFormError" class="formError justify marginTop"></p>
<div class="right marginTop">
<button type="submit">Sign up</button>
</div>
</form>
</div>
</div>
<?php require ROOT . '/views/templates/main_footer.php'; ?>

View File

@ -2,7 +2,22 @@
<h1> <h1>
<a href="/" title="MapGuesser"> <a href="/" title="MapGuesser">
<img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>"> <img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>">
MapGuesser <span>MapGuesser</span>
</a> </a>
</h1> </h1>
<p class="header">
<?php if (Container::$request->user()) : ?>
<span><a href="/profile" title="Profile">
<?php /* Copyright (c) 2019 The Bootstrap Authors. License can be found in 'USED_SOFTWARE' in section 'Bootstrap Icons'. */ ?>
<svg class="inline" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
</svg>
<?= Container::$request->user()->getDisplayName() ?><!--
--></a></span><!--
--><span><a href="/logout" title="Logout">Logout</a></span>
<?php else : ?>
<span><a href="/signup" title="Login">Sign up</a></span><!--
--><span><a href="/login" title="Login">Login</a></span>
<?php endif; ?>
</p>
</div> </div>

View File

@ -28,6 +28,6 @@
<div id="cover"></div> <div id="cover"></div>
<div id="modal" class="modal"> <div id="modal" class="modal">
<h2 id="modalTitle"></h2> <h2 id="modalTitle"></h2>
<p id="modalText" class="marginTop"></p> <p id="modalText" class="justify marginTop"></p>
<div id="modalButtons" class="right"></div> <div id="modalButtons" class="right"></div>
</div> </div>

12
web.php
View File

@ -15,7 +15,13 @@ Container::$routeCollection = new MapGuesser\Routing\RouteCollection();
Container::$routeCollection->get('index', '', [MapGuesser\Controller\HomeController::class, 'getIndex']); Container::$routeCollection->get('index', '', [MapGuesser\Controller\HomeController::class, 'getIndex']);
Container::$routeCollection->get('login', 'login', [MapGuesser\Controller\LoginController::class, 'getLoginForm']); Container::$routeCollection->get('login', 'login', [MapGuesser\Controller\LoginController::class, 'getLoginForm']);
Container::$routeCollection->post('login-action', 'login', [MapGuesser\Controller\LoginController::class, 'login']); Container::$routeCollection->post('login-action', 'login', [MapGuesser\Controller\LoginController::class, 'login']);
Container::$routeCollection->get('signup', 'signup', [MapGuesser\Controller\SignupController::class, 'getSignupForm']);
Container::$routeCollection->post('signup-action', 'signup', [MapGuesser\Controller\SignupController::class, 'signup']);
Container::$routeCollection->get('signup.activate', 'signup/activate/{token}', [MapGuesser\Controller\SignupController::class, 'activate']);
Container::$routeCollection->get('signup.cancel', 'signup/cancel/{token}', [MapGuesser\Controller\SignupController::class, 'cancel']);
Container::$routeCollection->get('logout', 'logout', [MapGuesser\Controller\LoginController::class, 'logout']); Container::$routeCollection->get('logout', 'logout', [MapGuesser\Controller\LoginController::class, 'logout']);
Container::$routeCollection->get('profile', 'profile', [MapGuesser\Controller\UserController::class, 'getProfile']);
Container::$routeCollection->post('profile-action', 'profile', [MapGuesser\Controller\UserController::class, 'saveProfile']);
Container::$routeCollection->get('maps', 'maps', [MapGuesser\Controller\MapsController::class, 'getMaps']); Container::$routeCollection->get('maps', 'maps', [MapGuesser\Controller\MapsController::class, 'getMaps']);
Container::$routeCollection->group('game', function (MapGuesser\Routing\RouteCollection $routeCollection) { Container::$routeCollection->group('game', function (MapGuesser\Routing\RouteCollection $routeCollection) {
$routeCollection->get('game', '{mapId}', [MapGuesser\Controller\GameController::class, 'getGame']); $routeCollection->get('game', '{mapId}', [MapGuesser\Controller\GameController::class, 'getGame']);
@ -40,6 +46,8 @@ session_start([
'cookie_samesite' => 'Lax' 'cookie_samesite' => 'Lax'
]); ]);
if (!isset($_SESSION['anti_csrf_token'])) { Container::$request = new MapGuesser\Request\Request($_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST'], $_GET, $_POST, $_SESSION);
$_SESSION['anti_csrf_token'] = hash('sha256', random_bytes(10) . microtime());
if (!Container::$request->session()->has('anti_csrf_token')) {
Container::$request->session()->set('anti_csrf_token', hash('sha256', random_bytes(10) . microtime()));
} }