diff --git a/docker/Dockerfile b/docker/Dockerfile index 2e68c87..0b3e3d4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,16 +2,14 @@ FROM ubuntu:focal ENV DEBIAN_FRONTEND noninteractive -# Install Apache, PHP and further necessary packages -RUN apt update -RUN apt install -y curl git mariadb-client apache2 \ +# Install Nginx, PHP and further necessary packages +RUN apt update --fix-missing +RUN apt install -y curl git mariadb-client nginx \ php-apcu php-xdebug php7.4-cli php7.4-curl php7.4-fpm php7.4-mbstring php7.4-mysql php7.4-zip -# Configure Apache with PHP +# Configure Nginx with PHP RUN mkdir -p /run/php -RUN a2enmod proxy_fcgi rewrite -RUN a2enconf php7.4-fpm -COPY configs/apache.conf /etc/apache2/sites-available/000-default.conf +COPY configs/nginx.conf /etc/nginx/sites-available/default RUN echo "xdebug.remote_enable = 1" >> /etc/php/7.4/mods-available/xdebug.ini RUN echo "xdebug.remote_autostart = 1" >> /etc/php/7.4/mods-available/xdebug.ini RUN echo "xdebug.remote_connect_back = 1" >> /etc/php/7.4/mods-available/xdebug.ini @@ -29,4 +27,4 @@ EXPOSE 80 VOLUME /var/www/mapguesser WORKDIR /var/www/mapguesser -ENTRYPOINT /usr/sbin/php-fpm7.4 -F & /usr/sbin/apache2ctl -DFOREGROUND +ENTRYPOINT /usr/sbin/php-fpm7.4 -F & /usr/sbin/nginx -g 'daemon off;' diff --git a/docker/configs/apache.conf b/docker/configs/apache.conf deleted file mode 100644 index 36c8063..0000000 --- a/docker/configs/apache.conf +++ /dev/null @@ -1,15 +0,0 @@ - - ServerName mapguesser-dev.ch - - ServerAdmin webmaster@localhost - DocumentRoot /var/www/mapguesser/public - - ErrorLog ${APACHE_LOG_DIR}/error.log - CustomLog ${APACHE_LOG_DIR}/access.log combined - - - - Options FollowSymLinks - AllowOverride All - Require all granted - diff --git a/docker/configs/nginx.conf b/docker/configs/nginx.conf new file mode 100644 index 0000000..e566a0c --- /dev/null +++ b/docker/configs/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + + root /var/www/mapguesser/public; + + index index.php index.html index.htm index.nginx-debian.html; + + server_name mapguesser-dev.ch; + + location / { + try_files $uri $uri/ /index.php?$args; + } + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; + } + + location ~ /\.ht { + deny all; + } +} diff --git a/main.php b/main.php index 75a9a94..72e92f1 100644 --- a/main.php +++ b/main.php @@ -18,8 +18,10 @@ if (!empty($_ENV['DEV'])) { class Container { static MapGuesser\Interfaces\Database\IConnection $dbConnection; + static MapGuesser\Routing\RouteCollection $routeCollection; } Container::$dbConnection = new MapGuesser\Database\Mysql\Connection($_ENV['DB_HOST'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'], $_ENV['DB_NAME']); +Container::$routeCollection = new MapGuesser\Routing\RouteCollection(); session_start(); diff --git a/public/index.php b/public/index.php index 0d592cc..681a0d9 100644 --- a/public/index.php +++ b/public/index.php @@ -4,36 +4,36 @@ require '../main.php'; // very basic routing $host = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_NAME']; -$url = $_SERVER['REQUEST_URI']; +$method = strtolower($_SERVER['REQUEST_METHOD']); +$url = substr($_SERVER['REQUEST_URI'], strlen('/')); if (($pos = strpos($url, '?')) !== false) { $url = substr($url, 0, $pos); } -switch($url) { - case '/maps': - $controller = new MapGuesser\Controller\MapsController(); - break; - case '/game': - $mapId = isset($_GET['map']) ? (int) $_GET['map'] : 0; - $controller = new MapGuesser\Controller\GameController($mapId); - break; - case '/game.json': - $mapId = isset($_GET['map']) ? (int) $_GET['map'] : 0; - $controller = new MapGuesser\Controller\GameController($mapId, true); - break; - case '/position.json': - $mapId = isset($_GET['map']) ? (int) $_GET['map'] : 0; - $controller = new MapGuesser\Controller\PositionController($mapId); - break; - case '/': - header('Location: ' . $host . '/maps', true, 302); - die; - default: - echo 'Error 404'; - die; +$url = rawurldecode($url); + +Container::$routeCollection->get('index', '', [MapGuesser\Controller\HomeController::class, 'getIndex']); +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']); + $routeCollection->get('game-json', '{mapId}/json', [MapGuesser\Controller\GameController::class, 'getGameJson']); + $routeCollection->get('position-json', '{mapId}/position.json', [MapGuesser\Controller\PositionController::class, 'getPosition']); + $routeCollection->post('guess-json', '{mapId}/guess.json', [MapGuesser\Controller\PositionController::class, 'evaluateGuess']); +}); + +$match = Container::$routeCollection->match($method, explode('/', $url)); + +if ($match !== null) { + list($route, $params) = $match; + + $response = $route->callController($params); + + 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()); + } +} else { + header('Content-Type: text/html; charset=UTF-8', true, 404); + require ROOT . '/views/error/404.php'; } - -$view = $controller->run(); - -header('Content-Type: ' . $view->getContentType() . '; charset=UTF-8'); - -echo $view->render(); diff --git a/public/static/js/game.js b/public/static/js/game.js index 8ccb9b2..00f0489 100644 --- a/public/static/js/game.js +++ b/public/static/js/game.js @@ -53,7 +53,7 @@ Core.startNewRound(); }; - xhr.open('GET', 'position.json?map=' + mapId, true); + xhr.open('GET', '/game/' + mapId + '/position.json', true); xhr.send(); }, @@ -135,7 +135,7 @@ Core.resetGame(); }; - xhr.open('GET', 'game.json?map=' + mapId, true); + xhr.open('GET', '/game/' + mapId + '/position.json', true); xhr.send(); }, @@ -160,7 +160,6 @@ document.getElementById('cover').style.visibility = 'visible'; var data = new FormData(); - data.append('guess', '1'); data.append('lat', String(guessPosition.lat)); data.append('lng', String(guessPosition.lng)); @@ -209,7 +208,7 @@ Core.panoId = this.response.panoId; }; - xhr.open('POST', 'position.json?map=' + mapId, true); + xhr.open('POST', '/game/' + mapId + '/guess.json', true); xhr.send(data); }, diff --git a/src/Controller/GameController.php b/src/Controller/GameController.php index c1174f4..246f9b8 100644 --- a/src/Controller/GameController.php +++ b/src/Controller/GameController.php @@ -1,51 +1,48 @@ mapId = $mapId; - $this->jsonResponse = $jsonResponse; + $mapId = (int) $parameters['mapId']; + $data = $this->prepareGame($mapId); + return new HtmlContent('game', $data); } - public function run(): IView + public function getGameJson(array $parameters): IContent { - $bounds = $this->getMapBounds(); + $mapId = (int) $parameters['mapId']; + $data = $this->prepareGame($mapId); + return new JsonContent($data); + } - if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $this->mapId) { + private function prepareGame(int $mapId) + { + $bounds = $this->getMapBounds($mapId); + + if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) { $_SESSION['state'] = [ - 'mapId' => $this->mapId, + 'mapId' => $mapId, 'area' => $bounds->calculateApproximateArea(), 'rounds' => [] ]; } - $data = ['mapId' => $this->mapId, 'bounds' => $bounds->toArray()]; - - if ($this->jsonResponse) { - return new JsonView($data); - } else { - return new HtmlView('game', $data); - } + return ['mapId' => $mapId, 'bounds' => $bounds->toArray()]; } - private function getMapBounds(): Bounds + private function getMapBounds(int $mapId): Bounds { $select = new Select(\Container::$dbConnection, 'maps'); $select->columns(['bound_south_lat', 'bound_west_lng', 'bound_north_lat', 'bound_east_lng']); - $select->whereId($this->mapId); + $select->whereId($mapId); $map = $select->execute()->fetch(IResultSet::FETCH_ASSOC); diff --git a/src/Controller/HomeController.php b/src/Controller/HomeController.php new file mode 100644 index 0000000..ae2fa61 --- /dev/null +++ b/src/Controller/HomeController.php @@ -0,0 +1,12 @@ +getRoute('maps'), []], IRedirect::TEMPORARY); + } +} diff --git a/src/Controller/MapsController.php b/src/Controller/MapsController.php index 569d667..46aa8b3 100644 --- a/src/Controller/MapsController.php +++ b/src/Controller/MapsController.php @@ -2,15 +2,14 @@ use MapGuesser\Database\Query\Select; use MapGuesser\Database\RawExpression; -use MapGuesser\Interfaces\Controller\IController; use MapGuesser\Interfaces\Database\IResultSet; -use MapGuesser\Interfaces\View\IView; +use MapGuesser\Interfaces\Response\IContent; use MapGuesser\Util\Geo\Bounds; -use MapGuesser\View\HtmlView; +use MapGuesser\Response\HtmlContent; -class MapsController implements IController +class MapsController { - public function run(): IView + public function getMaps(): IContent { $select = new Select(\Container::$dbConnection, 'maps'); $select->columns([ @@ -38,7 +37,7 @@ class MapsController implements IController } $data = ['maps' => $maps]; - return new HtmlView('maps', $data); + return new HtmlContent('maps', $data); } private function formatMapAreaForHuman(float $area): array diff --git a/src/Controller/PositionController.php b/src/Controller/PositionController.php index cddee8d..e248446 100644 --- a/src/Controller/PositionController.php +++ b/src/Controller/PositionController.php @@ -2,75 +2,30 @@ use MapGuesser\Database\Query\Select; use MapGuesser\Http\Request; -use MapGuesser\Interfaces\Controller\IController; use MapGuesser\Interfaces\Database\IResultSet; use MapGuesser\Util\Geo\Position; -use MapGuesser\View\JsonView; -use MapGuesser\Interfaces\View\IView; +use MapGuesser\Response\JsonContent; +use MapGuesser\Interfaces\Response\IContent; -class PositionController implements IController +class PositionController { const NUMBER_OF_ROUNDS = 5; const MAX_SCORE = 1000; - private int $mapId; - - public function __construct(int $mapId) + public function getPosition(array $parameters): IContent { - $this->mapId = $mapId; - } + $mapId = (int) $parameters['mapId']; - public function run(): IView - { - if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $this->mapId) { + if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) { $data = ['error' => 'No valid session found!']; - return new JsonView($data); + return new JsonContent($data); } if (count($_SESSION['state']['rounds']) === 0) { - $newPosition = $this->getNewPosition(); + $newPosition = $this->getNewPosition($mapId); $_SESSION['state']['rounds'][] = $newPosition; $data = ['panoId' => $newPosition['panoId']]; - } elseif (isset($_POST['guess'])) { - $last = &$_SESSION['state']['rounds'][count($_SESSION['state']['rounds']) - 1]; - - $position = $last['position']; - $guessPosition = new Position((float) $_POST['lat'], (float) $_POST['lng']); - - $last['guessPosition'] = $guessPosition; - - $distance = $this->calculateDistance($position, $guessPosition); - $score = $this->calculateScore($distance, $_SESSION['state']['area']); - - $last['distance'] = $distance; - $last['score'] = $score; - - if (count($_SESSION['state']['rounds']) < static::NUMBER_OF_ROUNDS) { - $exclude = []; - - foreach ($_SESSION['state']['rounds'] as $round) { - $exclude = array_merge($exclude, $round['placesWithoutPano'], [$round['placeId']]); - } - - $newPosition = $this->getNewPosition($exclude); - $_SESSION['state']['rounds'][] = $newPosition; - - $panoId = $newPosition['panoId']; - } else { - $_SESSION['state']['rounds'] = []; - - $panoId = null; - } - - $data = [ - 'result' => [ - 'position' => $position->toArray(), - 'distance' => $distance, - 'score' => $score - ], - 'panoId' => $panoId - ]; } else { $rounds = count($_SESSION['state']['rounds']); $last = $_SESSION['state']['rounds'][$rounds - 1]; @@ -92,15 +47,65 @@ class PositionController implements IController ]; } - return new JsonView($data); + return new JsonContent($data); } - private function getNewPosition(array $exclude = []): array + public function evaluateGuess(array $parameters): IContent + { + $mapId = (int) $parameters['mapId']; + + if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) { + $data = ['error' => 'No valid session found!']; + return new JsonContent($data); + } + + $last = &$_SESSION['state']['rounds'][count($_SESSION['state']['rounds']) - 1]; + + $position = $last['position']; + $guessPosition = new Position((float) $_POST['lat'], (float) $_POST['lng']); + + $last['guessPosition'] = $guessPosition; + + $distance = $this->calculateDistance($position, $guessPosition); + $score = $this->calculateScore($distance, $_SESSION['state']['area']); + + $last['distance'] = $distance; + $last['score'] = $score; + + if (count($_SESSION['state']['rounds']) < static::NUMBER_OF_ROUNDS) { + $exclude = []; + + foreach ($_SESSION['state']['rounds'] as $round) { + $exclude = array_merge($exclude, $round['placesWithoutPano'], [$round['placeId']]); + } + + $newPosition = $this->getNewPosition($mapId, $exclude); + $_SESSION['state']['rounds'][] = $newPosition; + + $panoId = $newPosition['panoId']; + } else { + $_SESSION['state']['rounds'] = []; + + $panoId = null; + } + + $data = [ + 'result' => [ + 'position' => $position->toArray(), + 'distance' => $distance, + 'score' => $score + ], + 'panoId' => $panoId + ]; + return new JsonContent($data); + } + + private function getNewPosition(int $mapId, array $exclude = []): array { $placesWithoutPano = []; do { - $place = $this->selectNewPlace($exclude); + $place = $this->selectNewPlace($mapId, $exclude); $position = new Position($place['lat'], $place['lng']); $panoId = $this->getPanorama($position); @@ -117,12 +122,12 @@ class PositionController implements IController ]; } - private function selectNewPlace(array $exclude): array + private function selectNewPlace(int $mapId, array $exclude): array { $select = new Select(\Container::$dbConnection, 'places'); $select->columns(['id', 'lat', 'lng']); $select->where('id', 'NOT IN', $exclude); - $select->where('map_id', '=', $this->mapId); + $select->where('map_id', '=', $mapId); $numberOfPlaces = $select->count();// TODO: what if 0 $randomOffset = random_int(0, $numberOfPlaces - 1); diff --git a/src/Interfaces/Controller/IController.php b/src/Interfaces/Controller/IController.php deleted file mode 100644 index 07caeeb..0000000 --- a/src/Interfaces/Controller/IController.php +++ /dev/null @@ -1,8 +0,0 @@ -target = $target; + $this->type = $type; + } + + public function getUrl(): string + { + if (is_array($this->target)) { + $link = $this->target[0]->generateLink($this->target[1]); + } else { + $link = $this->target; + } + + return $link; + } + + public function getHttpCode(): int + { + switch ($this->type) { + case IRedirect::PERMANENT: + return 301; + + case IRedirect::TEMPORARY: + return 302; + + default: + return 302; + } + } +} diff --git a/src/Routing/Route.php b/src/Routing/Route.php new file mode 100644 index 0000000..af5f659 --- /dev/null +++ b/src/Routing/Route.php @@ -0,0 +1,76 @@ +id = $id; + $this->pattern = $pattern; + $this->handler = $handler; + } + + public function getId(): string + { + return $this->id; + } + + public function generateLink(array $parameters = []): string + { + $link = []; + + foreach ($this->pattern as $fragment) { + if (preg_match('/^{(\\w+)(\\?)?}$/', $fragment, $matches)) { + if (isset($parameters[$matches[1]])) { + $link[] = $parameters[$matches[1]]; + unset($parameters[$matches[1]]); + } elseif (!isset($matches[2])) {//TODO: why? parameter not found but not optional + $link[] = $fragment; + } + } else { + $link[] = $fragment; + } + } + + $queryParams = []; + foreach ($parameters as $key => $value) { + if ($value === null) { + continue; + } + + $queryParams[$key] = $value; + } + + $query = count($queryParams) > 0 ? '?' . http_build_query($queryParams) : ''; + + 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 = []; + + foreach ($path as $i => $fragment) { + if (preg_match('/^{(\\w+)(?:\\?)?}$/', $this->pattern[$i], $matches)) { + $parameters[$matches[1]] = $fragment; + } elseif ($fragment != $this->pattern[$i]) { + return null; + } + } + + return $parameters; + } +} diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php new file mode 100644 index 0000000..c729c3e --- /dev/null +++ b/src/Routing/RouteCollection.php @@ -0,0 +1,83 @@ + [], + 'post' => [] + ]; + + private array $groupStack = []; + + public function get(string $id, string $pattern, array $handler): void + { + $this->addRoute('get', $id, $pattern, $handler); + } + + public function post(string $id, string $pattern, array $handler): void + { + $this->addRoute('post', $id, $pattern, $handler); + } + + public function group(string $pattern, Closure $group): void + { + $this->groupStack[] = $pattern; + + $group($this); + + array_pop($this->groupStack); + } + + public function getRoute(string $id): ?Route + { + if (!isset($this->routes[$id])) { + return null; + } + + return $this->routes[$id]; + } + + public function match(string $method, array $uri): ?array + { + $groupNumber = count($uri); + + if (!isset($this->searchTable[$method][$groupNumber])) { + return null; + } + + foreach ($this->searchTable[$method][$groupNumber] as $route) { + if (($parameters = $route->testAgainst($uri)) !== null) { + return [$route, $parameters]; + } + } + + return null; + } + + private function addRoute(string $method, string $id, string $pattern, array $handler): void + { + if (isset($this->routes[$id])) { + throw new \Exception('Route already exists: ' . $id); + } + + $pattern = array_merge($this->groupStack, explode('/', $pattern)); + $route = new Route($id, $pattern, $handler); + + $groupNumber = count($pattern); + + $this->searchTable[$method][$groupNumber][] = $route; + + while (preg_match('/^{\\w+\\?}$/', end($pattern))) { + $groupNumber--; + array_pop($pattern); + + $this->searchTable[$method][$groupNumber][] = $route; + } + + $this->routes[$id] = $route; + } +} diff --git a/views/error/404.php b/views/error/404.php new file mode 100644 index 0000000..7538936 --- /dev/null +++ b/views/error/404.php @@ -0,0 +1,7 @@ + + +
+

404 | Page not found

+

The requested URL was not found on this server. Back to start.

+
+ \ No newline at end of file diff --git a/views/game.php b/views/game.php index 5732766..05f6157 100644 --- a/views/game.php +++ b/views/game.php @@ -1,9 +1,9 @@ - +

- - + + MapGuesser

@@ -49,5 +49,5 @@ var mapBounds = ; - - \ No newline at end of file + + \ No newline at end of file diff --git a/views/maps.php b/views/maps.php index d9cd188..1871506 100644 --- a/views/maps.php +++ b/views/maps.php @@ -1,10 +1,5 @@ - -
-

- - MapGuesser -

-
+ +

Playable maps

@@ -34,7 +29,7 @@

- Play this map + Play this map
@@ -44,4 +39,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/views/templates/header.php b/views/templates/header.php new file mode 100644 index 0000000..9e80e6f --- /dev/null +++ b/views/templates/header.php @@ -0,0 +1,8 @@ +
+

+ + + MapGuesser + +

+
\ No newline at end of file diff --git a/views/templates/main_header.php b/views/templates/main_header.php index 30ccb9f..1615c46 100644 --- a/views/templates/main_header.php +++ b/views/templates/main_header.php @@ -4,14 +4,14 @@ MapGuesser - + - - - - + + + +
- +
\ No newline at end of file