diff --git a/database/migrations/structure/20200606_2352_users.sql b/database/migrations/structure/20200606_2352_users.sql new file mode 100644 index 0000000..c3078a9 --- /dev/null +++ b/database/migrations/structure/20200606_2352_users.sql @@ -0,0 +1,8 @@ +CREATE TABLE `users` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `email` varchar(100) NOT NULL, + `password` varchar(60) NOT NULL, + `type` enum('user', 'admin') NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; diff --git a/mapg b/mapg index ab89fe9..6409a95 100755 --- a/mapg +++ b/mapg @@ -6,5 +6,6 @@ require 'main.php'; $app = new Symfony\Component\Console\Application('MapGuesser Console', ''); $app->add(new MapGuesser\Cli\DatabaseMigration()); +$app->add(new MapGuesser\Cli\AddUserCommand()); $app->run(); diff --git a/public/index.php b/public/index.php index 829da88..d90278f 100644 --- a/public/index.php +++ b/public/index.php @@ -12,6 +12,9 @@ if (($pos = strpos($url, '?')) !== false) { $url = rawurldecode($url); Container::$routeCollection->get('index', '', [MapGuesser\Controller\HomeController::class, 'getIndex']); +Container::$routeCollection->get('login', 'login', [MapGuesser\Controller\LoginController::class, 'getLoginForm']); +Container::$routeCollection->post('login-action', 'login', [MapGuesser\Controller\LoginController::class, 'login']); +Container::$routeCollection->get('logout', 'logout', [MapGuesser\Controller\LoginController::class, 'logout']); Container::$routeCollection->get('maps', 'maps', [MapGuesser\Controller\MapsController::class, 'getMaps']); Container::$routeCollection->group('game', function (MapGuesser\Routing\RouteCollection $routeCollection) { $routeCollection->get('game', '{mapId}', [MapGuesser\Controller\GameController::class, 'getGame']); @@ -30,15 +33,32 @@ $match = Container::$routeCollection->match($method, explode('/', $url)); if ($match !== null) { list($route, $params) = $match; - $response = $route->callController($params); + $request = new MapGuesser\Request\Request($_GET, $params, $_POST, $_SESSION); - if ($response instanceof MapGuesser\Interfaces\Response\IContent) { - header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8'); - echo $response->render(); - } elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) { - header('Location: ' . $host . '/' . $response->getUrl(), true, $response->getHttpCode()); + $handler = $route->getHandler(); + $controller = new $handler[0]($request); + + if ($controller instanceof MapGuesser\Interfaces\Authorization\ISecured) { + $authorized = $controller->authorize(); + } else { + $authorized = true; + } + + if ($authorized) { + $response = call_user_func([$controller, $handler[1]]); + + if ($response instanceof MapGuesser\Interfaces\Response\IContent) { + header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8'); + echo $response->render(); + + return; + } elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) { + header('Location: ' . $host . '/' . $response->getUrl(), true, $response->getHttpCode()); + + return; + } } -} else { - header('Content-Type: text/html; charset=UTF-8', true, 404); - require ROOT . '/views/error/404.php'; } + +header('Content-Type: text/html; charset=UTF-8', true, 404); +require ROOT . '/views/error/404.php'; diff --git a/public/static/css/mapguesser.css b/public/static/css/mapguesser.css index d96bc8a..e95792f 100644 --- a/public/static/css/mapguesser.css +++ b/public/static/css/mapguesser.css @@ -17,7 +17,7 @@ button::-moz-focus-inner, input::-moz-focus-inner { border: 0; } -p, h1, h2, button, a { +p, h1, h2, input, textarea, select, button, a { font-family: 'Roboto', sans-serif; } @@ -93,6 +93,10 @@ sub { margin-bottom: 10px; } +.right { + text-align: right; +} + svg.inline, img.inline { display: inline-block; width: 1em; @@ -158,6 +162,54 @@ button.red:hover, button.red:focus, a.button.red:hover, a.button.red:focus { background-color: #7f2929; } +input, select, textarea { + background-color: #f9fafb; + border: solid #c8d2e1 1px; + border-radius: 2px; + padding: 4px; + box-sizing: border-box; + font-size: 15px; + font-weight: 300; +} + +textarea { + font-size: 13px; + resize: none; +} + +input.big, select.big, textarea.big { + padding: 5px; + font-size: 18px; +} + +input.fullWidth, select.fullWidth, textarea.fullWidth { + display: block; + width: 100%; +} + +input:disabled, select:disabled, textarea:disabled { + background-color: #dfdfdf; + border: solid #dfdfdf 1px; + color: #000000; +} + +input:focus, select:focus, textarea:focus { + background-color: #ffffff; + border: solid #29457f 2px; + padding: 3px; + outline: none; +} + +input.big:focus, select.big:focus, textarea.big:focus { + padding: 4px; +} + +p.formError { + color: #7f2929; + font-weight: 500; + display: none; +} + div.header { background-color: #333333; height: 50px; @@ -200,6 +252,15 @@ div.buttonContainer>button { visibility: hidden; } +div.box { + width: 576px; + background-color: #eeeeee; + border-radius: 3px; + margin: 10px auto; + padding: 10px; + box-sizing: border-box; +} + @media screen and (max-width: 599px) { div.header.small h1 span { display: none; @@ -208,4 +269,7 @@ div.buttonContainer>button { padding: 0; width: 100%; } + div.box { + width: initial; + } } diff --git a/public/static/js/login.js b/public/static/js/login.js new file mode 100644 index 0000000..0297b14 --- /dev/null +++ b/public/static/js/login.js @@ -0,0 +1,42 @@ +(function () { + var form = document.getElementById('loginForm'); + + form.onsubmit = function (e) { + document.getElementById('loading').style.visibility = 'visible'; + + e.preventDefault(); + + var formData = new FormData(form); + + var xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.onload = function () { + document.getElementById('loading').style.visibility = 'hidden'; + + if (this.response.error) { + var errorText; + switch (this.response.error) { + case 'user_not_found': + errorText = 'No user found with the given email address.'; + break; + case 'password_not_match': + errorText = 'The given password is wrong.' + break; + } + + var loginFormError = document.getElementById('loginFormError'); + loginFormError.style.display = 'block'; + loginFormError.innerHTML = errorText; + + form.elements.email.select(); + + return; + } + + window.location.replace('/'); + }; + + xhr.open('POST', form.action, true); + xhr.send(formData); + }; +})(); diff --git a/src/Cli/AddUserCommand.php b/src/Cli/AddUserCommand.php new file mode 100644 index 0000000..45fb57d --- /dev/null +++ b/src/Cli/AddUserCommand.php @@ -0,0 +1,51 @@ +setName('user:add') + ->setDescription('Adding of user.') + ->addArgument('email', InputArgument::REQUIRED, 'Email of user') + ->addArgument('password', InputArgument::REQUIRED, 'Password of user') + ->addArgument('type', InputArgument::OPTIONAL, 'Type of user');; + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $user = new User([ + 'email' => $input->getArgument('email'), + ]); + + $user->setPlainPassword($input->getArgument('password')); + + if ($input->hasArgument('type')) { + $user->setType($input->getArgument('type')); + } + + try { + $modify = new Modify(\Container::$dbConnection, 'users'); + $modify->fill($user->toArray()); + $modify->save(); + } catch (\Exception $e) { + $output->writeln('Adding user failed!'); + $output->writeln(''); + + $output->writeln((string) $e); + $output->writeln(''); + + return 1; + } + + $output->writeln('User was successfully added!'); + + return 0; + } +} diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index 246f9b8..1664623 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -2,6 +2,7 @@ use MapGuesser\Database\Query\Select; use MapGuesser\Interfaces\Database\IResultSet; +use MapGuesser\Interfaces\Request\IRequest; use MapGuesser\Util\Geo\Bounds; use MapGuesser\Response\HtmlContent; use MapGuesser\Response\JsonContent; @@ -9,16 +10,23 @@ use MapGuesser\Interfaces\Response\IContent; class GameController { - public function getGame(array $parameters): IContent + private IRequest $request; + + public function __construct(IRequest $request) { - $mapId = (int) $parameters['mapId']; + $this->request = $request; + } + + public function getGame(): IContent + { + $mapId = (int) $this->request->query('mapId'); $data = $this->prepareGame($mapId); return new HtmlContent('game', $data); } - public function getGameJson(array $parameters): IContent + public function getGameJson(): IContent { - $mapId = (int) $parameters['mapId']; + $mapId = (int) $this->request->query('mapId'); $data = $this->prepareGame($mapId); return new JsonContent($data); } @@ -27,12 +35,14 @@ class GameController { $bounds = $this->getMapBounds($mapId); - if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) { - $_SESSION['state'] = [ + $session = $this->request->session(); + + if (($state = $session->get('state')) && $state['mapId'] !== $mapId) { + $session->set('state', [ 'mapId' => $mapId, 'area' => $bounds->calculateApproximateArea(), 'rounds' => [] - ]; + ]); } return ['mapId' => $mapId, 'bounds' => $bounds->toArray()]; diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php new file mode 100644 index 0000000..5f124d4 --- /dev/null +++ b/src/Controller/LoginController.php @@ -0,0 +1,73 @@ +request = $request; + } + + public function getLoginForm() + { + $session = $this->request->session(); + + if ($session->get('user')) { + return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY); + } + + $data = []; + return new HtmlContent('login', $data); + } + + public function login(): IContent + { + $session = $this->request->session(); + + if ($session->get('user')) { + $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) { + $data = ['error' => 'user_not_found']; + return new JsonContent($data); + } + + $user = new User($userData); + + if (!$user->checkPassword($this->request->post('password'))) { + $data = ['error' => 'password_not_match']; + return new JsonContent($data); + } + + $session->set('user', $user); + + $data = ['success' => true]; + return new JsonContent($data); + } + + public function logout(): IRedirect + { + $this->request->session()->delete('user'); + + return new Redirect([\Container::$routeCollection->getRoute('login'), []], IRedirect::TEMPORARY); + } +} diff --git a/src/Controller/MapAdminController.php b/src/Controller/MapAdminController.php index a0bd507..d96f8dc 100644 --- a/src/Controller/MapAdminController.php +++ b/src/Controller/MapAdminController.php @@ -1,22 +1,35 @@ request = $request; $this->placeRepository = new PlaceRepository(); } + public function authorize(): bool + { + $user = $this->request->user(); + + return $user !== null && $user->hasPermission(IUser::PERMISSION_ADMIN); + } + public function getMaps(): IContent { //TODO @@ -24,9 +37,9 @@ class MapAdminController return new HtmlContent('admin/maps'); } - public function getMapEditor(array $parameters): IContent + public function getMapEditor(): IContent { - $mapId = (int) $parameters['mapId']; + $mapId = (int) $this->request->query('mapId'); $bounds = $this->getMapBounds($mapId); @@ -36,9 +49,9 @@ class MapAdminController return new HtmlContent('admin/map_editor', $data); } - public function getPlace(array $parameters) + public function getPlace() { - $placeId = (int) $parameters['placeId']; + $placeId = (int) $this->request->query('placeId'); $placeData = $this->placeRepository->getById($placeId); diff --git a/src/Controller/PositionController.php b/src/Controller/PositionController.php index 1564730..b804797 100644 --- a/src/Controller/PositionController.php +++ b/src/Controller/PositionController.php @@ -1,5 +1,6 @@ request = $request; $this->placeRepository = new PlaceRepository(); } - public function getPosition(array $parameters): IContent + public function getPosition(): IContent { - $mapId = (int) $parameters['mapId']; + $mapId = (int) $this->request->query('mapId'); - if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) { + $session = $this->request->session(); + + if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) { $data = ['error' => 'no_session_found']; return new JsonContent($data); } - if (count($_SESSION['state']['rounds']) === 0) { + if (count($state['rounds']) === 0) { $newPosition = $this->placeRepository->getForMapWithValidPano($mapId); - $_SESSION['state']['rounds'][] = $newPosition; + $state['rounds'][] = $newPosition; + $session->set('state', $state); $data = ['panoId' => $newPosition['panoId']]; } else { - $rounds = count($_SESSION['state']['rounds']); - $last = $_SESSION['state']['rounds'][$rounds - 1]; + $rounds = count($state['rounds']); + $last = $state['rounds'][$rounds - 1]; $history = []; for ($i = 0; $i < $rounds - 1; ++$i) { - $round = $_SESSION['state']['rounds'][$i]; + $round = $state['rounds'][$i]; $history[] = [ 'position' => $round['position']->toArray(), 'guessPosition' => $round['guessPosition']->toArray(), @@ -55,41 +62,45 @@ class PositionController return new JsonContent($data); } - public function evaluateGuess(array $parameters): IContent + public function evaluateGuess(): IContent { - $mapId = (int) $parameters['mapId']; + $mapId = (int) $this->request->query('mapId'); - if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) { + $session = $this->request->session(); + + if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) { $data = ['error' => 'no_session_found']; return new JsonContent($data); } - $last = &$_SESSION['state']['rounds'][count($_SESSION['state']['rounds']) - 1]; + $last = $state['rounds'][count($state['rounds']) - 1]; $position = $last['position']; - $guessPosition = new Position((float) $_POST['lat'], (float) $_POST['lng']); - - $last['guessPosition'] = $guessPosition; + $guessPosition = new Position((float) $this->request->post('lat'), (float) $this->request->post('lng')); $distance = $this->calculateDistance($position, $guessPosition); - $score = $this->calculateScore($distance, $_SESSION['state']['area']); + $score = $this->calculateScore($distance, $state['area']); + $last['guessPosition'] = $guessPosition; $last['distance'] = $distance; $last['score'] = $score; + $state['rounds'][count($state['rounds']) - 1] = $last; - if (count($_SESSION['state']['rounds']) < static::NUMBER_OF_ROUNDS) { + if (count($state['rounds']) < static::NUMBER_OF_ROUNDS) { $exclude = []; - foreach ($_SESSION['state']['rounds'] as $round) { + foreach ($state['rounds'] as $round) { $exclude = array_merge($exclude, $round['placesWithoutPano'], [$round['placeId']]); } $newPosition = $this->placeRepository->getForMapWithValidPano($mapId, $exclude); - $_SESSION['state']['rounds'][] = $newPosition; + $state['rounds'][] = $newPosition; + $session->set('state', $state); $panoId = $newPosition['panoId']; } else { - $_SESSION['state']['rounds'] = []; + $state['rounds'] = []; + $session->set('state', $state); $panoId = null; } diff --git a/src/Interfaces/Authentication/IUser.php b/src/Interfaces/Authentication/IUser.php new file mode 100644 index 0000000..363cfe8 --- /dev/null +++ b/src/Interfaces/Authentication/IUser.php @@ -0,0 +1,10 @@ + $value) { + $method = 'set' . str_replace('_', '', ucwords($key, '_')); + + if (method_exists($this, $method)) { + $this->$method($value); + } + } + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getId() + { + return $this->id; + } + + function toArray(): array + { + $array = []; + + foreach (self::getFields() as $key) { + $method = 'get' . str_replace('_', '', ucwords($key, '_')); + + if (method_exists($this, $method)) { + $array[$key] = $this->$method(); + } + } + + return $array; + } +} diff --git a/src/Model/User.php b/src/Model/User.php new file mode 100644 index 0000000..cdc34dd --- /dev/null +++ b/src/Model/User.php @@ -0,0 +1,70 @@ +email = $email; + } + + public function setPassword(string $hashedPassword): void + { + $this->password = $hashedPassword; + } + + public function setPlainPassword(string $plainPassword): void + { + $this->password = password_hash($plainPassword, PASSWORD_BCRYPT); + } + + public function setType(string $type): void + { + if (in_array($type, self::$types)) { + $this->type = $type; + } + } + + public function getEmail(): string + { + return $this->email; + } + + public function getPassword(): string + { + return $this->password; + } + + public function getType(): string + { + return $this->type; + } + + public function hasPermission(int $permission): bool + { + switch ($permission) { + case IUser::PERMISSION_NORMAL: + return true; + break; + case IUser::PERMISSION_ADMIN: + return $this->type === 'admin'; + break; + } + } + + public function checkPassword(string $password): bool + { + return password_verify($password, $this->password); + } +} diff --git a/src/Request/Request.php b/src/Request/Request.php new file mode 100644 index 0000000..2c10f16 --- /dev/null +++ b/src/Request/Request.php @@ -0,0 +1,57 @@ +get = &$get; + $this->routeParams = &$routeParams; + $this->post = &$post; + $this->session = new Session($session); + } + + public function query($key) + { + if (isset($this->get[$key])) { + return $this->get[$key]; + } + + if (isset($this->routeParams[$key])) { + return $this->routeParams[$key]; + } + + return null; + } + + public function post($key) + { + if (isset($this->post[$key])) { + return $this->post[$key]; + } + + return null; + } + + public function session(): ISession + { + return $this->session; + } + + public function user(): ?IUser + { + return $this->session->get('user'); + } +} diff --git a/src/Request/Session.php b/src/Request/Session.php new file mode 100644 index 0000000..f1fedab --- /dev/null +++ b/src/Request/Session.php @@ -0,0 +1,37 @@ +data = &$data; + } + + public function has($key): bool + { + return isset($this->data[$key]); + } + + public function get($key) + { + if (isset($this->data[$key])) { + return $this->data[$key]; + } + + return null; + } + + public function set($key, $value): void + { + $this->data[$key] = $value; + } + + public function delete($key): void + { + unset($this->data[$key]); + } +} diff --git a/src/Routing/Route.php b/src/Routing/Route.php index af5f659..afe5491 100644 --- a/src/Routing/Route.php +++ b/src/Routing/Route.php @@ -20,6 +20,11 @@ class Route return $this->id; } + public function getHandler(): array + { + return $this->handler; + } + public function generateLink(array $parameters = []): string { $link = []; @@ -51,14 +56,6 @@ class Route return implode('/', $link) . $query; } - public function callController(array $parameters) - { - $controllerName = $this->handler[0]; - $controller = new $controllerName(); - - return call_user_func([$controller, $this->handler[1]], $parameters); - } - public function testAgainst(array $path): ?array { $parameters = []; diff --git a/views/login.php b/views/login.php new file mode 100644 index 0000000..565e7aa --- /dev/null +++ b/views/login.php @@ -0,0 +1,17 @@ + + +
+

Login

+
+
+ + +

+
+ +
+
+
+
+ + \ No newline at end of file