Compare commits

...
Author SHA1 Message Date
bence a4c77c3d32 Merged in bugfix/MAPG-196-fix-compatibility-issues (pull request #180)
MAPG-196 revert link preload to plain stylesheet because it is not supported everywhere
2020-07-06 19:15:32 +00:00
bence 317329ae84 MAPG-196 revert link preload to plain stylesheet because it is not supported everywhere 2020-07-06 21:12:56 +02:00
bence 234cb7f086 Merged in feature/MAPG-196-fix-issues-reported-by-lighthouse (pull request #179)
Feature/MAPG-196 fix issues reported by lighthouse
2020-07-06 18:05:51 +00:00
bence 66c709f00d MAPG-196 reposition of cookie notice 2020-07-06 20:04:39 +02:00
bence 2737c7d6d6 MAPG-196 speed up asset loading, add missing description, add missing image alts 2020-07-06 20:04:39 +02:00
bence 2ccee26424 Merged in feature/MAPG-199-static-analyser-save-artifact (pull request #178)
MAPG-199 save static code analysis results as artifact
2020-07-06 16:52:21 +00:00
bence 8cbf611189 MAPG-199 save static code analysis results as artifact 2020-07-06 18:50:17 +02:00
bence 2ac9794e3f Merged in feature/MAPG-199-introduce-static-php-analyser (pull request #177)
Feature/MAPG-199 introduce static php analyser
2020-07-05 21:23:24 +00:00
bence 9e5f7405d1 MAPG-199 move test views to another folder to prevent code analysis errors 2020-07-05 23:18:57 +02:00
bence af98d71924 MAPG-199 fix issues found by PHPStan 2020-07-05 23:18:56 +02:00
bence 39d691fd7f MAPG-199 add static code analysis step to pipeline 2020-07-05 22:51:36 +02:00
bence 405488cf4e MAPG-199 add PHPStan to require-dev 2020-07-05 21:35:49 +02:00
bence f2736d4d6e Merged in hotfix/MAPG-198-fix-not-working-game (pull request #176)
MAPG-198 re-add table name to Select
2020-07-05 19:19:53 +00:00
bence 88b9821c61 MAPG-198 re-add table name to Select
(table name is used when counting)
2020-07-05 21:17:29 +02:00
bence da2c8d96a4 Merged in bugfix/MAPG-142-fix-timestamp-onupdate (pull request #175)
MAPG-142 fix timestamp onupdate issue
2020-07-05 16:24:17 +00:00
bence cd581c6fcd MAPG-142 fix timestamp onupdate issue 2020-07-05 18:23:02 +02:00
bence f7b268e10f Merged in feature/MAPG-185-create-cron-script (pull request #174)
Feature/MAPG-185 create cron script
2020-07-05 15:56:12 +00:00
bence a8b1ab3057 MAPG-185 adapt README 2020-07-05 17:52:13 +02:00
bence 8403c21ec0 MAPG-185 consolidation of email templates 2020-07-05 17:37:02 +02:00
bence 0ba1cda884 MAPG-185 fix getter of User 2020-07-05 17:36:27 +02:00
bence b2e09c394b MAPG-185 switch off builtin session gc 2020-07-05 16:48:37 +02:00
bence 0bcb0187c1 MAPG-185 rename migrate database command 2020-07-05 16:45:38 +02:00
bence f3d1608164 MAPG-185 add maintain database command 2020-07-05 16:43:36 +02:00
bence b254603fb1 MAPG-185 add getter for expired users and password resetters 2020-07-05 16:37:37 +02:00
bence 776561a41c Merged in feature/MAPG-193-replace-direct-selects-to-repository-calls (pull request #173)
Feature/MAPG-193 replace direct selects to repository calls
2020-07-05 13:20:08 +00:00
bence 27fc687043 MAPG-193 replace Select usage to repository calls where it is easily possible 2020-07-05 15:17:28 +02:00
bence 45869321ac MAPG-193 fix PersistentDataManager::selectMultipleFromDb 2020-07-05 15:15:29 +02:00
bence b58137acc9 Merged in feature/MAPG-142-resend-activation-link-functionality (pull request #172)
Feature/MAPG-142 resend activation link functionality
2020-07-05 12:32:32 +00:00
bence dd6bb5ef9a MAPG-142 limit password reset query if the existing is not expired 2020-07-05 14:24:06 +02:00
bence 37094e552b MAPG-142 modify UserPasswordResetterRepository to return only one resetter for user
(it is not possible for an user to have multiple)
2020-07-05 14:24:06 +02:00
bence 32392590bd MAPG-142 save user creation date 2020-07-05 14:24:06 +02:00
bence 38885849de MAPG-142 remove unnecessary 'now' from DateTime constructor 2020-07-05 14:24:05 +02:00
bence ef013b8b9e MAPG-142 rename app to MapGuesser in .env.example 2020-07-05 14:24:05 +02:00
bence ab23b37b97 MAPG-142 redefine tokens and increase OAuth security with nonce 2020-07-05 14:24:05 +02:00
bence 7e3315fc88 MAPG-142 implemenet confirmation mail resend 2020-07-05 13:39:56 +02:00
bence 6cafac1b65 MAPG-142 modify UserConfirmationRepository to return only one confirmation for user
(it is not possible for an user to have multiple)
2020-07-05 13:33:02 +02:00
bence 631582a912 Merged in feature/MAPG-191-get-rid-of-unnecessary-passing-by-reference (pull request #171)
Feature/MAPG-191 get rid of unnecessary passing by reference
2020-07-04 23:02:57 +00:00
bence 6db6208896 MAPG-191 don't create separate variable for data where not necessary 2020-07-05 00:58:03 +02:00
bence e17cf68007 MAPG-191 don't pass data by reference to IContent 2020-07-05 00:25:34 +02:00
bence 091afb0aab Merged in feature/MAPG-141-password-forgotten-functionality (pull request #170)
Feature/MAPG-141 password forgotten functionality
2020-07-04 22:15:50 +00:00
bence 954f111254 MAPG-141 delete password resetters when deleting account 2020-07-05 00:09:45 +02:00
bence de1d7338a4 MAPG-141 add reset password functionality 2020-07-05 00:09:11 +02:00
bence 7539f637b0 MAPG-141 add and fix some links to login/signup 2020-07-05 00:04:35 +02:00
bence 67534a22f5 MAPG-141 add password resetter table, model and repository 2020-07-05 00:03:56 +02:00
bence 43aef22c4e Merged in feature/MAPG-44-possibility-to-add-pov-data-to-locations (pull request #169)
Feature/MAPG-44 possibility to add pov data to locations
2020-07-03 23:12:56 +00:00
bence 3123643bb7 MAPG-44 modify game to use pov of place 2020-07-04 01:11:04 +02:00
bence b736ce8970 MAPG-44 modify MapAdminController to get/save place pov data 2020-07-04 01:10:03 +02:00
bence 4e48e6ae73 MAPG-44 add Pov class and adjust Place model to store pov 2020-07-04 01:09:00 +02:00
bence 028d8a22a2 MAPG-44 modify DB structure to store pov data 2020-07-04 01:06:37 +02:00
bence a73e60ae5a Merged in bugfix/MAPG-189-fix-inputwithbuttons-input-padding (pull request #168)
MAPG-189 fix div.inputWithButton>input's padding when it is focused
2020-07-03 21:51:38 +00:00
bence 5383da81b3 MAPG-189 fix div.inputWithButton>input's padding when it is focused 2020-07-03 23:50:22 +02:00
bence e8adc9f730 Merged in feature/MAPG-177-create-unit-tests-for-all-units (pull request #167)
MAPG-177 add tests for View\Parser
2020-06-28 21:13:32 +00:00
bence 149003250c MAPG-177 add tests for View\Parser 2020-06-28 22:55:15 +02:00
bence 171b4278a6 Merged in feature/MAPG-175-unify-styles (pull request #166)
MAPG-175 make map title one liner
2020-06-28 18:16:23 +00:00
bence 091018d22f MAPG-175 make map title one liner 2020-06-28 20:15:11 +02:00
bence 34f0c92eb5 Merged in bugfix/MAPG-187-dont-evaluate-asset-paths (pull request #165)
Bugfix/MAPG-187 dont evaluate asset paths
2020-06-28 17:38:03 +00:00
bence 8ac5d5c6e2 MAPG-187 adapt views to the new parser 2020-06-28 19:35:14 +02:00
bence e883d184b7 MAPG-187 refactor view parsing and linking 2020-06-28 19:35:14 +02:00
bence 71b1e28aa9 Merged in feature/MAPG-188-pagespeed-improvements (pull request #164)
MAPG-188 get rid of inline js
2020-06-28 16:13:16 +00:00
bence 3555be9b63 MAPG-188 get rid of inline js 2020-06-28 17:56:47 +02:00
bence d03a3934c6 Merged in feature/MAPG-181-handle-the-case-when-user-has-no-password (pull request #163)
Feature/MAPG-181 handle the case when user has no password
2020-06-28 08:42:10 +00:00
bence bd5b49b09b MAPG-181 set user active when added from CLI 2020-06-28 03:19:07 +02:00
bence 4333240acc MAPG-181 add stuff to authenticate without password 2020-06-28 03:18:51 +02:00
bence 4099f1d962 MAPG-181 add new layout type (minimal) 2020-06-28 03:16:48 +02:00
bence 2fac828098 MAPG-181 text fixes 2020-06-28 03:16:28 +02:00
bence f3225ae275 MAPG-181 ability to add login_hint to dialog url 2020-06-28 03:15:20 +02:00
bence 48eba75cf4 MAPG-181 fix and unify styles for buttons and inputs 2020-06-28 03:12:16 +02:00
bence a97a6f8977 Merged in bugfix/MAPG-180-dont-echo-void (pull request #162)
MAPG-180 don't echo void
2020-06-27 12:26:57 +00:00
bence 2a3f891aa0 MAPG-180 don't echo void 2020-06-27 14:26:19 +02:00
bence 38a427416c Merged in bugfix/MAPG-180-unify-inline-js (pull request #161)
MAPG-180 don't parse REVISION in view linking time
2020-06-27 11:21:02 +00:00
bence 2f14e1f4c3 MAPG-180 don't parse REVISION in view linking time 2020-06-27 13:20:11 +02:00
bence 6098b7bb8d Merged in feature/MAPG-175-unify-styles (pull request #160)
MAPG-175 add theme color
2020-06-27 11:11:49 +00:00
bence 596b29f841 MAPG-175 add theme color 2020-06-27 13:09:24 +02:00
bence 34a4026ddc Merged in feature/MAPG-180-inline-js-css-automatically (pull request #159)
Feature/MAPG-180 inline js css automatically
2020-06-27 11:02:21 +00:00
bence a55ece7dac MAPG-180 don't buffer content output to variable 2020-06-27 12:56:47 +02:00
bence efdd5c54be MAPG-180 ability to inline CSS and JS assets 2020-06-27 12:51:00 +02:00
bence 825e0744ec MAPG-180 rename pagescript to pageScript 2020-06-27 12:47:13 +02:00
bence 18bf9aaade MAPG-180 declare CSS and JS files 2020-06-27 02:25:54 +02:00
bence 5e645a1313 Merged in hotfix/MAPG-184-show-runtime (pull request #158)
MAPG-184 measure runtime and show in web output
2020-06-26 23:21:02 +00:00
bence 9c9edcbbd1 MAPG-184 measure runtime and show in web output 2020-06-27 01:19:17 +02:00
bence de009637de Merged in feature/MAPG-89-create-a-better-templating-engine (pull request #156)
Feature/MAPG-89 create a better templating engine
2020-06-26 22:50:14 +00:00
bence 6fce002458 MAPG-89 unify styles 2020-06-27 00:47:14 +02:00
bence 1287fa0bc9 MAPG-89 rewrite views and view templates to the new concept 2020-06-27 00:47:14 +02:00
bence bf8e5b5f4d MAPG-89 adaptations in HtmlContent and index.php 2020-06-27 00:44:38 +02:00
bence c8a49396c2 MAPG-89 install and update sh scripts to link views in non-DEV mode 2020-06-27 00:44:38 +02:00
bence c2323c4c89 MAPG-89 add command that can link views from command line 2020-06-27 00:44:38 +02:00
bence 60cbe64718 MAPG-89 add cache folder that can hold cache files 2020-06-27 00:44:38 +02:00
bence c48bbdc3c2 MAPG-89 add classes that can link views into one PHP 2020-06-27 00:44:38 +02:00
bence ee6aa59253 Merged in bugfix/MAPG-183-tooling-fixes (pull request #157)
MAPG-183 tooling fixes
2020-06-26 22:40:14 +00:00
bence e1df28123f MAPG-183 tooling fixes 2020-06-27 00:38:19 +02:00
bence b4769b62dd Merged in feature/MAPG-177-create-unit-tests-for-all-units (pull request #155)
Feature/MAPG-177 create unit tests for all units
2020-06-25 22:33:56 +00:00
bence bee991e300 MAPG-177 add test for Model 2020-06-26 00:28:37 +02:00
bence ddc56426c2 MAPG-177 add test for GoogleOAuth 2020-06-26 00:28:37 +02:00
bence c06dd1e1d2 MAPG-177 refactor GoogleOAuth, Http\Request, Http\Response to be more testable 2020-06-26 00:28:37 +02:00
bence fd657b4244 MAPG-177 add test for JwtParser 2020-06-26 00:28:37 +02:00
bence 532f099ec9 Merged in feature/MAPG-180-unify-inline-js (pull request #154)
Feature/MAPG-180 unify inline js
2020-06-25 20:41:47 +00:00
bence ec969167e4 MAPG-180 standardize form submit with AJAX 2020-06-25 22:35:04 +02:00
bence e4a51c139f MAPG-180 improve form functionality 2020-06-25 22:34:32 +02:00
bence 27115e44b6 MAPG-180 extract all inline JS into separate JS file 2020-06-25 20:42:12 +02:00
bence 45904a98c2 Merged in feature/MAPG-156-delete-account-functionality (pull request #153)
Feature/MAPG-156 delete account functionality
2020-06-25 18:30:42 +00:00
bence b1ae7391e7 MAPG-156 implement user deletion 2020-06-25 20:26:51 +02:00
bence 8987b563dd MAPG-156 add getByUser to UserConfirmationRepository 2020-06-25 20:26:51 +02:00
bence 70d8807f38 MAPG-156 extend PDM to be able to return with Generator 2020-06-25 20:26:51 +02:00
bence 910fdddf34 MAPG-156 adapt account's JS to the new helper functions 2020-06-25 20:26:51 +02:00
bence 87c4c06aa6 MAPG-156 add some form helper functions 2020-06-25 20:26:51 +02:00
bence ad24f8ac28 MAPG-156 rename profile to account 2020-06-25 16:44:34 +02:00
bence e2067627c4 Merged in feature/MAPG-179-extend-the-readme (pull request #152)
MAPG-179 add basic stuff to the README
2020-06-25 13:33:27 +00:00
bence a8419535e1 MAPG-179 export MariaDB server port 2020-06-25 15:30:38 +02:00
bence 725d14400b MAPG-179 add basic stuff to the README 2020-06-25 15:30:21 +02:00
bence b75eb16b3d Merged in feature/MAPG-178-add-google-analytics (pull request #151)
Feature/MAPG-178 add google analytics
2020-06-25 12:06:43 +00:00
bence f8a570d0d5 MAPG-178 add Google Analytics
style adaptations
cookie notice improvements
2020-06-25 14:02:39 +02:00
bence e7ee7bbe8e MAPG-178 add endpoint that can send valid data based on started session 2020-06-25 14:01:17 +02:00
bence 2f4f66cc94 MAPG-178 make style compatible with cookie notice on the top of the page 2020-06-25 14:00:38 +02:00
bence 4afbc046ff MAPG-178 add GOOGLE_ANALITICS_ID to .env.example 2020-06-25 13:57:05 +02:00
bence 79bd5b98d5 Merged in feature/MAPG-94-unittest-output (pull request #150)
MAPG-94 save test results and save only the result xml as artifact
2020-06-25 08:17:45 +00:00
bence c1cdeba1d3 MAPG-94 save test results and save only the result xml as artifact 2020-06-25 10:16:28 +02:00
bence 255168ffb6 Merged in bugfix/MAPG-94-introduce-automatic-unit-testing (pull request #147)
MAPG-94 fix possible syntax error in yml
2020-06-24 22:45:01 +00:00
bence ca0b8da733 MAPG-94 fix possible syntax error in yml 2020-06-25 00:44:24 +02:00
bence 34206cac12 Merged in feature/MAPG-94-introduce-automatic-unit-testing (pull request #146)
Feature/MAPG-94 introduce automatic unit testing
2020-06-24 22:38:36 +00:00
bence 47a2f15fc5 MAPG-94 don't install Composer dev packages during update 2020-06-25 00:37:48 +02:00
bence 17658f70ba MAPG-94 create Bitbucket pipeline for unit testing 2020-06-25 00:35:36 +02:00
bence 500ec958cb MAPG-94 add tests for Util\Geo 2020-06-25 00:35:36 +02:00
bence d2fa2050ac MAPG-94 install php-xml to Docker because it is a dependency of PHPUnit
install unzip as well because it is sometimes needed
2020-06-25 00:35:36 +02:00
bence e8be433ee6 MAPG-94 add PHPUnit to composer.json 2020-06-25 00:27:01 +02:00
bence 506072017c Merged in hotfix/MAPG-172-create-cookie-notice (pull request #145)
MAPG-172 make variable local
2020-06-24 21:22:05 +00:00
bence 4561c825a4 MAPG-172 make variable local 2020-06-24 23:21:14 +02:00
bence 693bddbcf0 Merged in hotfix/MAPG-172-create-cookie-notice (pull request #144)
MAPG-172 don't close the cookie DIV if not the agree button was clicked
2020-06-24 21:01:34 +00:00
bence eae7cefae1 MAPG-172 don't close the cookie DIV if not the agree button was clicked 2020-06-24 23:00:26 +02:00
bence 212728e138 Merged in bugfix/MAPG-172-create-cookie-notice (pull request #143)
MAPG-172 quote I agree
2020-06-24 20:55:36 +00:00
bence 0d627f297e MAPG-172 quote I agree 2020-06-24 22:55:13 +02:00
bence f07ae979a2 Merged in feature/MAPG-175-unify-styles (pull request #142)
MAPG-175 align noPano middle and center as other elements
2020-06-24 20:37:59 +00:00
bence 8bdbbafcff MAPG-175 align noPano middle and center as other elements 2020-06-24 22:37:33 +02:00
bence 3856ccb576 Merged in feature/MAPG-174-adjust-map-description-style (pull request #141)
MAPG-174 create separate div for description and align description vertically middle
2020-06-24 20:34:00 +00:00
bence 9f839d3f5d MAPG-174 create separate div for description and align description vertically middle 2020-06-24 22:32:54 +02:00
bence 721b611cdb Merged in feature/MAPG-174-adjust-map-description-style (pull request #140)
MAPG-174 align description center
2020-06-24 20:11:08 +00:00
bence 70fb30f673 MAPG-174 align description center 2020-06-24 22:10:24 +02:00
bence e200dab2a4 Merged in feature/MAPG-172-create-cookie-notice (pull request #139)
Feature/MAPG-172 create cookie notice
2020-06-24 20:03:29 +00:00
bence e8c246f002 MAPG-172 add cookie consent DIV and JS handler 2020-06-24 21:57:41 +02:00
bence 251bf1be3d MAPG-172 add new styles 2020-06-24 21:57:40 +02:00
bence 0be20a6097 MAPG-172 don't send session if user didn't consent the cookies 2020-06-24 21:57:40 +02:00
125 changed files with 5043 additions and 874 deletions

No files matched your search

+2 -1
View File
@@ -1,4 +1,4 @@
APP_NAME=MapQuiz APP_NAME=MapGuesser
DEV=1 DEV=1
DB_HOST=mariadb DB_HOST=mariadb
DB_USER=mapguesser DB_USER=mapguesser
@@ -14,3 +14,4 @@ MAIL_HOST=mail
MAIL_PORT=2500 MAIL_PORT=2500
GOOGLE_OAUTH_CLIENT_ID=your_google_oauth_client_id GOOGLE_OAUTH_CLIENT_ID=your_google_oauth_client_id
GOOGLE_OAUTH_CLIENT_SECRET=your_google_oauth_client_secret GOOGLE_OAUTH_CLIENT_SECRET=your_google_oauth_client_secret
GOOGLE_ANALITICS_ID=your_google_analytics_id
+68 -2
View File
@@ -1,5 +1,71 @@
# MapGuesser # MapGuesser
This is the MapGuesser Application project. This is the MapGuesser Application project. This is a game about guessing where you are based on a street view panorama - inspired by existing applications.
License: GNU AGPL 3.0 ## Installation
### Clone the Git repository
The first step is obviously cloning the repository to your machine:
```
git clone https://bitbucket.org/esoko/mapguesser.git
```
All the commands listed here should be executed from the repository root.
### (Optional) Setup Docker stack
The easiest way to build up a fully working application with web server and database is to use Docker Compose with the included `docker-compose.yml`.
All you have to do is executing the following command:
```
docker-compose up -d
```
Attach shell to the container of `mapguesser_app`. All of the following commands should be executed there.
**If you don't use the Docker stack you need to install your environment manually. Check `docker-compose.yml` and `docker/Dockerfile` to see the system requirements.**
### Initialize project
This command installes all of the Composer requirements and creates a copy of the example `.env` file.
```
composer create-project
```
### Set environment variables
The `.env` file contains several environment variables that are needed by the application to work properly. These should be configured for your environment.
**You should set here the API keys that enable playing the game. Without these API keys the application cannot work well. To get Google API keys visit this page: https://console.developers.google.com/**
One very important variable is `DEV`. This indicates that the application operates in development (staging) and not in produciton mode.
If you install the application in the Docker stack for development (staging) environment, only the variables for external dependencies (API keys, map attribution, etc.) should be adapted. All other variables (for DB connection, static root, mailing, etc.) are fine with the default value.
### (Production only) Create cron job
To maintain database (delete inactive users, old sessions etc.), the command `db:maintain` should be regularly executed. It is recommened to create a cron job that runs every hour:
```
0 * * * * /path/to/your/installation/mapg db:maintain >>/var/log/cron-mapguesser.log 2>&1
```
### Finalize installation
After you set the environment variables in the `.env` file, execute the following command:
```
scripts/install.sh
```
**And you are done!** The application is ready to use and develop.
If you installed it in the Docker stack, you can reach it on http://localhost. The mails that are sent by the application can be found on http://localhost:8080/. If needed, the database server can be directly reached on localhost:3306.
---
*License: **GNU AGPL 3.0**. Full license text can be found in file `LICENSE`.*
+26
View File
@@ -0,0 +1,26 @@
image: php:7.4.7-cli-buster
pipelines:
default:
- step:
name: Unit Testing
caches:
- composer
artifacts:
- unit_test_results.xml
script:
- apt-get update && apt-get install -y unzip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install
- vendor/bin/phpunit --log-junit unit_test_results.xml --testdox tests
- step:
name: Static Code Analysis
caches:
- composer
artifacts:
- static_code_analysis_results.json
script:
- apt-get update && apt-get install -y unzip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install
- vendor/bin/phpstan analyse -c phpstan.neon --error-format=prettyJson > static_code_analysis_results.json
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+4 -1
View File
@@ -8,7 +8,10 @@
"symfony/console": "^5.1", "symfony/console": "^5.1",
"phpmailer/phpmailer": "^6.1" "phpmailer/phpmailer": "^6.1"
}, },
"require-dev": {}, "require-dev": {
"phpunit/phpunit": "^9",
"phpstan/phpstan": "^0.12.32"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"MapGuesser\\": "src" "MapGuesser\\": "src"
Generated
+1757 -2
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,8 @@
ALTER TABLE
`places`
ADD
`pov_heading` decimal(6, 3) NOT NULL DEFAULT 0.0,
ADD
`pov_pitch` decimal(5, 3) NOT NULL DEFAULT 0.0,
ADD
`pov_zoom` decimal(5, 4) NOT NULL DEFAULT 0.0;
@@ -0,0 +1,10 @@
CREATE TABLE `user_password_resetters` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`token` varchar(32) CHARACTER SET ascii NOT NULL,
`expires` timestamp NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `token` (`token`),
CONSTRAINT `user_password_resetters_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
@@ -0,0 +1,5 @@
UPDATE `user_confirmations` SET token=SUBSTRING(token, 1, 32);
ALTER TABLE `user_confirmations`
ADD `last_sent` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY `token` varchar(32) CHARACTER SET ascii NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE `sessions`
MODIFY `updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,4 @@
UPDATE `sessions` SET id=SUBSTRING(id, 1, 32);
ALTER TABLE `sessions`
MODIFY `id` varchar(32) CHARACTER SET ascii NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE `users`
ADD `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP;
+2
View File
@@ -11,6 +11,8 @@ services:
- 'mail' - 'mail'
mariadb: mariadb:
image: mariadb:10.3 image: mariadb:10.3
ports:
- 3306:3306
volumes: volumes:
- mysql:/var/lib/mysql - mysql:/var/lib/mysql
environment: environment:
+3 -3
View File
@@ -4,8 +4,8 @@ ENV DEBIAN_FRONTEND noninteractive
# Install Nginx, PHP and further necessary packages # Install Nginx, PHP and further necessary packages
RUN apt update --fix-missing RUN apt update --fix-missing
RUN apt install -y curl git mariadb-client nginx \ RUN apt install -y curl git unzip 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 php-apcu php-xdebug php7.4-cli php7.4-curl php7.4-fpm php7.4-mbstring php7.4-mysql php7.4-zip php7.4-xml
# Configure Nginx with PHP # Configure Nginx with PHP
RUN mkdir -p /run/php RUN mkdir -p /run/php
@@ -21,7 +21,7 @@ RUN ./install-composer.sh
# Install Node.js and required packages # Install Node.js and required packages
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - RUN curl -sL https://deb.nodesource.com/setup_14.x | bash -
RUN apt install -y nodejs RUN apt install -y nodejs
RUN npm install -g uglify-js clean-css-cli svgo RUN npm install -g uglify-js clean-css-cli svgo yarn
EXPOSE 80 EXPOSE 80
VOLUME /var/www/mapguesser VOLUME /var/www/mapguesser
+13
View File
@@ -0,0 +1,13 @@
Hi,
<br><br>
You recently requested password reset on {{APP_NAME}} with this email address ({{EMAIL}}).
To reset the password to your account, please click on the following link:<br>
<a href="{{RESET_LINK}}" title="Reset password">{{RESET_LINK}}</a>
<br><br>
You can reset your password with this link util {{EXPIRES}}.
<br><br>
If you did not requested password reset, no further action is required, your account is not touched.
<br><br>
Regards,<br>
{{APP_NAME}}<br>
<a href="{{BASE_URL}}" title="{{APP_NAME}}">{{BASE_URL}}</a>
+7 -3
View File
@@ -1,10 +1,14 @@
Hi, Hi,
<br><br> <br><br>
You recently signed up on {{APP_NAME}} with this email address ({{EMAIL}}). To activate your account, please click on the following link:<br> You recently signed up on {{APP_NAME}} with this email address ({{EMAIL}}).
To activate your account, please click on the following link:<br>
<a href="{{ACTIVATE_LINK}}" title="Account activation">{{ACTIVATE_LINK}}</a> <a href="{{ACTIVATE_LINK}}" title="Account activation">{{ACTIVATE_LINK}}</a>
<br><br> <br><br>
If you did not sign up on {{APP_NAME}} or changed your mind, no further action is required, your email address will be deleted soon.<br> You can activate your account until {{ACTIVATABLE_UNTIL}}.
However if you want to immediately delete it, please click on the following link:<br> If you don't activate your account, your email address will be permanently deleted after this point of time.
<br><br>
If you did not sign up on {{APP_NAME}} or changed your mind, no further action is required.
However if you want to immediately delete your email address, please click on the following link:<br>
<a href="{{CANCEL_LINK}}" title="Sign up cancellation">{{CANCEL_LINK}}</a> <a href="{{CANCEL_LINK}}" title="Sign up cancellation">{{CANCEL_LINK}}</a>
<br><br> <br><br>
Have fun on {{APP_NAME}}! Have fun on {{APP_NAME}}!
+3 -1
View File
@@ -1,5 +1,7 @@
<?php <?php
define('SCRIPT_STARTED', hrtime(true));
require 'vendor/autoload.php'; require 'vendor/autoload.php';
const ROOT = __DIR__; const ROOT = __DIR__;
@@ -14,7 +16,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 MapGuesser\Interfaces\Session\ISessionHandler $sessionHandler;
static MapGuesser\Interfaces\Request\IRequest $request; static MapGuesser\Interfaces\Request\IRequest $request;
} }
+3 -1
View File
@@ -5,7 +5,9 @@ require 'main.php';
$app = new Symfony\Component\Console\Application('MapGuesser Console', ''); $app = new Symfony\Component\Console\Application('MapGuesser Console', '');
$app->add(new MapGuesser\Cli\DatabaseMigration()); $app->add(new MapGuesser\Cli\MigrateDatabaseCommand());
$app->add(new MapGuesser\Cli\AddUserCommand()); $app->add(new MapGuesser\Cli\AddUserCommand());
$app->add(new MapGuesser\Cli\LinkViewCommand());
$app->add(new \MapGuesser\Cli\MaintainDatabaseCommand());
$app->run(); $app->run();
+9
View File
@@ -0,0 +1,9 @@
parameters:
level: 5
checkMissingIterableValueType: false
paths:
- main.php
- mapg
- web.php
- src
- tests
+5 -3
View File
@@ -26,8 +26,9 @@ if ($match !== null) {
} }
if ($method === 'post' && Container::$request->post('anti_csrf_token') !== Container::$request->session()->get('anti_csrf_token')) { if ($method === 'post' && Container::$request->post('anti_csrf_token') !== Container::$request->session()->get('anti_csrf_token')) {
$content = new MapGuesser\Response\JsonContent(['error' => 'no_valid_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']); $content->render();
return; return;
} }
@@ -36,7 +37,7 @@ if ($match !== null) {
if ($response instanceof MapGuesser\Interfaces\Response\IContent) { if ($response instanceof MapGuesser\Interfaces\Response\IContent) {
header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8'); header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8');
echo $response->render(); $response->render();
return; return;
} elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) { } elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) {
@@ -47,5 +48,6 @@ if ($match !== null) {
} }
} }
$content = new MapGuesser\Response\HtmlContent('error/404');
header('Content-Type: text/html; charset=UTF-8', true, 404); header('Content-Type: text/html; charset=UTF-8', true, 404);
require ROOT . '/views/error/404.php'; $content->render();
+11 -8
View File
@@ -1,12 +1,15 @@
#panorama { #panorama {
width: 100%; position: absolute;
height: calc(100% - 40px); top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 1; z-index: 1;
} }
#guessCover { #panoCover {
position: absolute; position: absolute;
top: 40px; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
@@ -111,7 +114,7 @@
z-index: 2; z-index: 2;
} }
#guess { #guess {
top: 50px; top: 10px;
left: 20px; left: 20px;
opacity: 0.95; opacity: 0.95;
visibility: hidden; visibility: hidden;
@@ -157,7 +160,7 @@
#guess.result { #guess.result {
width: initial; width: initial;
height: initial; height: initial;
top: 50px; top: 10px;
left: 50px; left: 50px;
right: 50px; right: 50px;
bottom: 50px; bottom: 50px;
@@ -167,11 +170,11 @@
} }
@media screen and (max-height: 424px) { @media screen and (max-height: 424px) {
#guess { #guess {
top: 50px; top: 10px;
height: initial; height: initial;
} }
#guess.adapt:hover { #guess.adapt:hover {
top: 50px; top: 10px;
height: initial; height: initial;
} }
#guess.result { #guess.result {
+18 -9
View File
@@ -1,9 +1,13 @@
#map { #map {
width: 100%; position: absolute;
height: calc(100% - 40px); top: 0;
left: 0;
bottom: 0;
right: 0;
z-index: 1; z-index: 1;
} }
/* modify the cursor for the Leaflet map */
.leaflet-container { .leaflet-container {
cursor: crosshair; cursor: crosshair;
} }
@@ -16,6 +20,8 @@
#noPano { #noPano {
display: flex; display: flex;
justify-content: center;
align-items: center;
position: absolute; position: absolute;
z-index: 2; z-index: 2;
visibility: hidden; visibility: hidden;
@@ -23,12 +29,12 @@
} }
#noPano>p { #noPano>p {
margin: auto; text-align: center;
} }
#control { #control {
position: absolute; position: absolute;
top: 50px; top: 10px;
right: 10px; right: 10px;
width: 125px; width: 125px;
z-index: 3; z-index: 3;
@@ -48,31 +54,34 @@
@media screen and (max-width: 999px) and (min-height: 600px) { @media screen and (max-width: 999px) and (min-height: 600px) {
#map.selected { #map.selected {
height: calc(50% - 20px); height: 50%;
} }
#panorama, #noPano { #panorama, #noPano {
left: 0; left: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
height: calc(50% - 20px); height: 50%;
} }
#placeControl { #placeControl {
top: calc(50% + 30px); top: calc(50% + 10px);
} }
} }
@media screen and (min-width: 1000px), (max-height: 599px) { @media screen and (min-width: 1000px), (max-height: 599px) {
#map.selected { #map.selected {
top: 0;
bottom: 0;
left: 0;
width: 50%; width: 50%;
} }
#panorama, #noPano { #panorama, #noPano {
top: 40px; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
width: 50%; width: 50%;
} }
#placeControl { #placeControl {
top: 50px; top: 10px;
} }
#modified.selected { #modified.selected {
right: calc(50% + 10px); right: calc(50% + 10px);
+92 -11
View File
@@ -17,6 +17,11 @@ button::-moz-focus-inner, input::-moz-focus-inner {
border: 0; border: 0;
} }
/* to be compatible with browsers that don't know <main> */
main {
display: block;
}
::selection { ::selection {
background-color: #28a745; background-color: #28a745;
color: #ffffff; color: #ffffff;
@@ -84,7 +89,7 @@ hr {
} }
p.small, span.small { p.small, span.small {
font-size: 12px; font-size: 14px;
} }
.justify { .justify {
@@ -148,6 +153,27 @@ button, a.button {
line-height: 35px; line-height: 35px;
} }
button.small, div.inputWithButton>button {
font-size: 14px;
padding: 0 12px;
height: 32px;
line-height: 32px;
}
button.small {
height: 32px;
line-height: 32px;
}
div.inputWithButton>button {
border-radius: 2px;
height: 27px;
line-height: 27px;
width: 75px;
margin-left: -79px;
vertical-align: 2px;
}
button:enabled:hover, button:enabled:focus, a.button:hover, a.button:focus { button:enabled:hover, button:enabled:focus, a.button:hover, a.button:focus {
background-color: #29457f; background-color: #29457f;
outline: none; outline: none;
@@ -179,7 +205,7 @@ button.gray, a.button.gray {
background-color: #808080; background-color: #808080;
} }
button.gray:hover, button.gray:focus, a.button.gray:hover, a.button.gray:focus { button.gray:enabled:hover, button.gray:enabled:focus, a.button.gray:hover, a.button.gray:focus {
background-color: #555555; background-color: #555555;
} }
@@ -187,7 +213,7 @@ button.red, a.button.red {
background-color: #aa5e5e; background-color: #aa5e5e;
} }
button.red:hover, button.red:focus, a.button.red:hover, a.button.red:focus { button.red:enabled:hover, button.red:enabled:focus, a.button.red:hover, a.button.red:focus {
background-color: #7f2929; background-color: #7f2929;
} }
@@ -195,7 +221,7 @@ button.yellow, a.button.yellow {
background-color: #e8a349; background-color: #e8a349;
} }
button.yellow:hover, button.yellow:focus, a.button.yellow:hover, a.button.yellow:focus { button.yellow:enabled:hover, button.yellow:enabled:focus, a.button.yellow:hover, a.button.yellow:focus {
background-color: #c37713; background-color: #c37713;
} }
@@ -203,7 +229,7 @@ button.green, a.button.green {
background-color: #28a745; background-color: #28a745;
} }
button.green:hover, button.green:focus, a.button.green:hover, a.button.green:focus { button.green:enabled:hover, button.green:enabled:focus, a.button.green:hover, a.button.green:focus {
background-color: #1b7d31; background-color: #1b7d31;
} }
@@ -211,22 +237,36 @@ input, select, textarea {
background-color: #f9fafb; background-color: #f9fafb;
border: solid #c8d2e1 1px; border: solid #c8d2e1 1px;
border-radius: 2px; border-radius: 2px;
padding: 4px;
box-sizing: border-box; box-sizing: border-box;
font-size: 15px; font-size: 15px;
font-weight: 300; font-weight: 300;
} }
input, select {
height: 30px;
line-height: 30px;
padding: 0 5px;
}
textarea { textarea {
font-size: 13px; padding: 5px;
resize: none; resize: none;
} }
input.big, select.big, textarea.big { input.big, select.big, textarea.big, div.inputWithButton>input {
padding: 5px;
font-size: 18px; font-size: 18px;
} }
input.big, select.big, div.inputWithButton>input {
height: 35px;
line-height: 35px;
padding: 0 6px;
}
textarea.big {
padding: 6px;
}
input.fullWidth, select.fullWidth, textarea.fullWidth { input.fullWidth, select.fullWidth, textarea.fullWidth {
display: block; display: block;
width: 100%; width: 100%;
@@ -241,14 +281,34 @@ input:disabled, select:disabled, textarea:disabled {
input:focus, select:focus, textarea:focus { input:focus, select:focus, textarea:focus {
background-color: #ffffff; background-color: #ffffff;
border: solid #29457f 2px; border: solid #29457f 2px;
padding: 3px;
outline: none; outline: none;
} }
input.big:focus, select.big:focus, textarea.big:focus { input:focus, select:focus {
padding: 0 4px;
}
textarea:focus {
padding: 4px; padding: 4px;
} }
input.big:focus, select.big:focus {
padding: 0 5px;
}
div.inputWithButton>input {
width: 100%;
padding: 0 83px 0 6px;
}
div.inputWithButton>input:focus {
padding: 0 82px 0 5px;
}
textarea.big:focus {
padding: 5px;
}
div.modal { div.modal {
position: fixed; position: fixed;
background-color: #ffffff; background-color: #ffffff;
@@ -317,6 +377,13 @@ main {
padding: 6px 12px; padding: 6px 12px;
} }
main.full {
position: relative;
width: 100%;
height: calc(100% - 40px);
padding: 0;
}
footer { footer {
background-color: #444444; background-color: #444444;
padding: 6px 12px; padding: 6px 12px;
@@ -336,6 +403,20 @@ div.buttonContainer>button {
margin: 0 auto; margin: 0 auto;
} }
#cookiesNotice {
position: fixed;
left: 0;
bottom: 0;
right: 0;
margin: 20px;
background-color: #eeeeee;
border: solid #888888 1px;
border-radius: 3px;
padding: 10px;
text-align: center;
z-index: 10;
}
#loading { #loading {
position: fixed; position: fixed;
width: 64px; width: 64px;
+16 -1
View File
@@ -18,7 +18,16 @@ div.mapItem>div.title {
color: white; color: white;
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-top-right-radius: 3px; border-top-right-radius: 3px;
padding: 4px 8px; padding: 0 8px;
height: 35px;
line-height: 35px;
}
div.mapItem>div.title>p {
line-height: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
div.mapItem>div.title>p.title { div.mapItem>div.title>p.title {
@@ -46,6 +55,12 @@ div.mapItem>div.inner>div.info {
grid-template-columns: auto auto; grid-template-columns: auto auto;
} }
div.mapItem>div.inner>div.description {
display: flex;
justify-content: center;
align-items: center;
}
div.mapItem>div.inner>div.info>p:nth-child(1) { div.mapItem>div.inner>div.info>p:nth-child(1) {
text-align: left; text-align: left;
} }
+69
View File
@@ -0,0 +1,69 @@
var Account = {
original: null,
countdown: null,
openGoogleAuthenticate: function () {
window.open('/account/googleAuthenticate', 'googleAuthenticate', 'height=600,width=600')
},
authenticatedWithGoogleCallback: function (authenticatedWithGoogleUntil) {
var password = document.getElementsByTagName('form')[0].elements.password;
var button = document.getElementById('authenticateWithGoogleButton');
Account.original = {
type: password.type,
placeholder: password.placeholder,
required: password.required,
disabled: password.disabled
};
password.type = 'text';
password.placeholder = 'Authenticated!'
password.value = '';
password.required = false;
password.disabled = true;
button.disabled = true;
Account.countdown = setInterval(function () {
var timeLeft = Math.ceil((authenticatedWithGoogleUntil.getTime() - new Date().getTime()) / 1000);
if (timeLeft > 30) {
return;
}
if (timeLeft <= 0) {
Account.resetGoogleAuthentication();
return;
}
password.placeholder = 'Authenticated! ' + timeLeft + ' seconds left...';
}, 1000);
},
resetGoogleAuthentication: function () {
if (Account.countdown !== null) {
clearInterval(Account.countdown);
}
var password = document.getElementsByTagName('form')[0].elements.password;
var button = document.getElementById('authenticateWithGoogleButton');
password.type = Account.original.type;
password.placeholder = Account.original.placeholder
password.required = Account.original.required;
password.disabled = Account.original.disabled;
button.disabled = false;
}
};
(function () {
document.getElementById('authenticateWithGoogleButton').onclick = function () {
Account.openGoogleAuthenticate();
};
document.getElementsByTagName('form')[0].onreset = function () {
Account.resetGoogleAuthentication();
};
})();
@@ -0,0 +1,10 @@
(function () {
if (success) {
window.opener.Account.authenticatedWithGoogleCallback(authenticatedWithGoogleUntil);
window.close();
} else {
document.getElementById('closeWindowButton').onclick = function () {
window.close();
}
}
})();
+19 -8
View File
@@ -8,6 +8,7 @@
rounds: [], rounds: [],
scoreSum: 0, scoreSum: 0,
panoId: null, panoId: null,
pov: null,
panorama: null, panorama: null,
map: null, map: null,
guessMarker: null, guessMarker: null,
@@ -16,7 +17,7 @@
initialize: function () { initialize: function () {
document.getElementById('loading').style.visibility = 'visible'; document.getElementById('loading').style.visibility = 'visible';
document.getElementById('guessCover').style.visibility = 'visible'; document.getElementById('panoCover').style.visibility = 'visible';
document.getElementById('currentRound').innerHTML = '1/' + String(Game.NUMBER_OF_ROUNDS); document.getElementById('currentRound').innerHTML = '1/' + String(Game.NUMBER_OF_ROUNDS);
document.getElementById('currentScoreSum').innerHTML = '0/0'; document.getElementById('currentScoreSum').innerHTML = '0/0';
@@ -27,7 +28,7 @@
MapGuesser.httpRequest('GET', '/game/' + mapId + '/newPlace.json', function () { MapGuesser.httpRequest('GET', '/game/' + mapId + '/newPlace.json', function () {
document.getElementById('loading').style.visibility = 'hidden'; document.getElementById('loading').style.visibility = 'hidden';
document.getElementById('guessCover').style.visibility = 'hidden'; document.getElementById('panoCover').style.visibility = 'hidden';
if (this.response.error) { if (this.response.error) {
//TODO: handle this error //TODO: handle this error
@@ -35,6 +36,7 @@
} }
Game.panoId = this.response.panoId; Game.panoId = this.response.panoId;
Game.pov = this.response.pov;
if (this.response.history) { if (this.response.history) {
for (var i = 0; i < this.response.history.length; ++i) { for (var i = 0; i < this.response.history.length; ++i) {
@@ -98,7 +100,7 @@
lastRound.line.setVisible(false); lastRound.line.setVisible(false);
} }
document.getElementById('guessCover').style.visibility = 'hidden'; document.getElementById('panoCover').style.visibility = 'hidden';
document.getElementById('showGuessButton').style.visibility = null; document.getElementById('showGuessButton').style.visibility = null;
document.getElementById('guess').style.visibility = null; document.getElementById('guess').style.visibility = null;
document.getElementById('guess').classList.remove('result') document.getElementById('guess').classList.remove('result')
@@ -116,7 +118,7 @@
document.getElementById('currentRound').innerHTML = String(Game.rounds.length) + '/' + String(Game.NUMBER_OF_ROUNDS); document.getElementById('currentRound').innerHTML = String(Game.rounds.length) + '/' + String(Game.NUMBER_OF_ROUNDS);
Game.loadPano(Game.panoId); Game.loadPano(Game.panoId, Game.pov);
}, },
handleErrorResponse: function (error) { handleErrorResponse: function (error) {
@@ -129,13 +131,13 @@
}); });
}, },
loadPano: function (panoId) { loadPano: function (panoId, pov) {
if (Game.adaptGuess) { if (Game.adaptGuess) {
document.getElementById('guess').classList.add('adapt'); document.getElementById('guess').classList.add('adapt');
} }
Game.panorama.setPov({ heading: 0, pitch: 0 }); Game.panorama.setPov({ heading: pov.heading, pitch: pov.pitch });
Game.panorama.setZoom(0); Game.panorama.setZoom(pov.zoom);
Game.panorama.setPano(panoId); Game.panorama.setPano(panoId);
}, },
@@ -152,7 +154,7 @@
document.getElementById('guess').classList.remove('adapt'); document.getElementById('guess').classList.remove('adapt');
} }
document.getElementById('loading').style.visibility = 'visible'; document.getElementById('loading').style.visibility = 'visible';
document.getElementById('guessCover').style.visibility = 'visible'; document.getElementById('panoCover').style.visibility = 'visible';
var data = new FormData(); var data = new FormData();
data.append('lat', String(guessPosition.lat)); data.append('lat', String(guessPosition.lat));
@@ -199,6 +201,7 @@
} }
Game.panoId = this.response.panoId; Game.panoId = this.response.panoId;
Game.pov = this.response.pov;
}, data); }, data);
}, },
@@ -370,6 +373,14 @@
} }
}; };
MapGuesser.sessionAvailableHooks.reinitializeGame = function () {
MapGuesser.httpRequest('GET', '/game/' + mapId + '/json', function () {
mapBounds = this.response.bounds;
Game.initialize();
});
};
if (!('ontouchstart' in document.documentElement)) { if (!('ontouchstart' in document.documentElement)) {
Game.adaptGuess = true; Game.adaptGuess = true;
} }
-43
View File
@@ -1,43 +0,0 @@
(function () {
var form = document.getElementById('loginForm');
form.onsubmit = function (e) {
document.getElementById('loading').style.visibility = 'visible';
e.preventDefault();
var formData = new FormData(form);
MapGuesser.httpRequest('POST', form.action, function () {
if (this.response.error) {
if (this.response.error === 'user_not_found') {
window.location.replace('/signup');
return;
}
var errorText;
switch (this.response.error) {
case 'password_too_short':
errorText = 'The given password is too short. Please choose a password that is at least 6 characters long!'
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;
case 'password_not_match':
errorText = 'The given password is wrong.'
break;
}
document.getElementById('loading').style.visibility = 'hidden';
var loginFormError = document.getElementById('loginFormError');
loginFormError.style.display = 'block';
loginFormError.innerHTML = errorText;
return;
}
window.location.replace('/');
}, formData);
};
})();
+9
View File
@@ -0,0 +1,9 @@
(function () {
document.getElementById('cancelGoogleSignupButton').onclick = function () {
document.getElementById('loading').style.visibility = 'visible';
MapGuesser.httpRequest('POST', '/signup/google/reset', function () {
window.location.replace('/signup');
});
};
})();
+12
View File
@@ -0,0 +1,12 @@
(function () {
var resetSignupButton = document.getElementById('resetSignupButton');
if (resetSignupButton) {
resetSignupButton.onclick = function () {
document.getElementById('loading').style.visibility = 'visible';
MapGuesser.httpRequest('POST', '/signup/reset', function () {
window.location.reload();
});
};
}
})();
+152 -3
View File
@@ -1,4 +1,49 @@
var MapGuesser = { var MapGuesser = {
cookiesAgreed: false,
sessionAvailableHooks: {},
initGoogleAnalitics: function () {
if (typeof GOOGLE_ANALITICS_ID === 'undefined') {
return;
}
// Global site tag (gtag.js) - Google Analytics
var script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + GOOGLE_ANALITICS_ID;
script.async = true;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', GOOGLE_ANALITICS_ID);
},
agreeCookies: function () {
if (MapGuesser.cookiesAgreed) {
return;
}
var expirationDate = new Date(new Date().getTime() + 20 * 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = 'COOKIES_CONSENT=1; expires=' + expirationDate + '; path=/';
MapGuesser.initGoogleAnalitics();
MapGuesser.httpRequest('GET', '/startSession.json', function () {
ANTI_CSRF_TOKEN = this.response.antiCsrfToken;
for (var hookId in MapGuesser.sessionAvailableHooks) {
if (!MapGuesser.sessionAvailableHooks.hasOwnProperty(hookId)) {
continue;
}
MapGuesser.sessionAvailableHooks[hookId]();
}
});
MapGuesser.cookiesAgreed = true;
},
httpRequest: function (method, url, callback, data) { httpRequest: function (method, url, callback, data) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@@ -21,6 +66,52 @@ var MapGuesser = {
} }
}, },
setOnsubmitForForm: function (form) {
form.onsubmit = function (e) {
e.preventDefault();
document.getElementById('loading').style.visibility = 'visible';
var formData = new FormData(form);
var formError = form.getElementsByClassName('formError')[0];
var pageLeaveOnSuccess = form.dataset.redirectOnSuccess || form.dataset.reloadOnSuccess;
MapGuesser.httpRequest('POST', form.action, function () {
if (!pageLeaveOnSuccess) {
document.getElementById('loading').style.visibility = 'hidden';
}
if (this.response.error) {
if (pageLeaveOnSuccess) {
document.getElementById('loading').style.visibility = 'hidden';
}
formError.style.display = 'block';
formError.innerHTML = this.response.error.errorText;
return;
}
if (this.response.redirect) {
window.location.replace(this.response.redirect.target);
return;
}
if (!pageLeaveOnSuccess) {
formError.style.display = 'none';
form.reset();
} else {
if (form.dataset.redirectOnSuccess) {
window.location.replace(form.dataset.redirectOnSuccess);
} else if (form.dataset.reloadOnSuccess) {
window.location.reload();
}
}
}, formData);
}
},
showModal: function (id) { showModal: function (id) {
document.getElementById(id).style.visibility = 'visible'; document.getElementById(id).style.visibility = 'visible';
document.getElementById('cover').style.visibility = 'visible'; document.getElementById('cover').style.visibility = 'visible';
@@ -91,11 +182,35 @@ var MapGuesser = {
document.getElementById('cover').style.visibility = 'hidden'; document.getElementById('cover').style.visibility = 'hidden';
}, },
toggleDisableOnChange: function (input, button) { observeInput: function (input, buttonToToggle) {
if (input.defaultValue !== input.value) { if (input.defaultValue !== input.value) {
button.disabled = false; buttonToToggle.disabled = false;
} else { } else {
button.disabled = true; buttonToToggle.disabled = true;
}
},
observeInputsInForm: function (form, observedInputs) {
for (var i = 0; i < observedInputs.length; i++) {
var input = form.elements[observedInputs[i]];
switch (input.tagName) {
case 'INPUT':
case 'TEXTAREA':
input.oninput = function () {
MapGuesser.observeInput(this, form.elements.submit);
};
break;
case 'SELECT':
input.onchange = function () {
MapGuesser.observeInput(this, form.elements.submit);
};
break;
}
}
form.onreset = function () {
form.elements.submit.disabled = true;
} }
} }
}; };
@@ -111,7 +226,41 @@ var MapGuesser = {
} }
} }
var forms = document.getElementsByTagName('form');
for (var i = 0; i < forms.length; i++) {
var form = forms[i];
if (form.dataset.noSubmit) {
continue;
}
MapGuesser.setOnsubmitForForm(form);
if (form.dataset.observeInputs) {
MapGuesser.observeInputsInForm(form, form.dataset.observeInputs.split(','));
}
}
document.getElementById('cover').onclick = function () { document.getElementById('cover').onclick = function () {
MapGuesser.hideModal(); MapGuesser.hideModal();
}; };
if (COOKIES_CONSENT) {
MapGuesser.initGoogleAnalitics();
} else {
// we don't want user to agree cookies when clicking on the notice itself
document.getElementById('cookiesNotice').onclick = function (e) {
e.stopPropagation();
};
document.getElementById('agreeCookiesButton').onclick = function () {
MapGuesser.agreeCookies();
document.getElementById('cookiesNotice').style.display = 'none';
};
window.onclick = function () {
MapGuesser.agreeCookies();
};
}
})(); })();
+76
View File
@@ -0,0 +1,76 @@
(function () {
var Maps = {
descriptionDivs: null,
addStaticMaps: function () {
var imgContainers = document.getElementById('mapContainer').getElementsByClassName('imgContainer');
for (var i = 0; i < imgContainers.length; i++) {
var imgContainer = imgContainers[i];
var imgSrc = 'https://maps.googleapis.com/maps/api/staticmap?size=350x175&' +
'scale=' + (window.devicePixelRatio >= 2 ? 2 : 1) + '&' +
'visible=' + imgContainer.dataset.boundSouthLat + ',' + imgContainer.dataset.boundWestLng + '|' +
imgContainer.dataset.boundNorthLat + ',' + imgContainer.dataset.boundEastLng +
'&key=' + GOOGLE_MAPS_JS_API_KEY;
imgContainer.style.backgroundImage = 'url("' + imgSrc + '")';
}
},
initializeDescriptionDivs: function () {
Maps.descriptionDivs = document.getElementById('mapContainer').getElementsByClassName('description');
for (var i = 0; i < Maps.descriptionDivs.length; i++) {
var description = Maps.descriptionDivs[i];
var boundingClientRect = description.getBoundingClientRect();
description.defaultHeight = boundingClientRect.height;
}
},
calculateDescriptionDivHeights: function () {
var currentY;
var rows = [];
for (var i = 0; i < Maps.descriptionDivs.length; i++) {
var description = Maps.descriptionDivs[i];
var boundingClientRect = description.getBoundingClientRect();
if (currentY !== boundingClientRect.y) {
rows.push([]);
}
rows[rows.length - 1].push(description);
currentY = boundingClientRect.y;
}
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var maxHeight = 0;
for (var j = 0; j < row.length; j++) {
var description = row[j];
if (description.defaultHeight > maxHeight) {
maxHeight = description.defaultHeight;
}
}
for (var j = 0; j < row.length; j++) {
var description = row[j];
description.style.height = maxHeight + 'px';
}
}
}
};
Maps.addStaticMaps();
Maps.initializeDescriptionDivs();
Maps.calculateDescriptionDivHeights();
window.onresize = function () {
Maps.calculateDescriptionDivHeights();
};
})();
+34
View File
@@ -0,0 +1,34 @@
(function () {
Maps = {
deleteMap: function (mapId, mapName) {
MapGuesser.showModalWithContent('Delete map', 'Are you sure you want to delete map \'' + mapName + '\'?', [{
type: 'button',
classNames: ['red'],
text: 'Delete',
onclick: function () {
document.getElementById('loading').style.visibility = 'visible';
MapGuesser.httpRequest('POST', '/admin/deleteMap/' + mapId, function () {
if (this.response.error) {
document.getElementById('loading').style.visibility = 'hidden';
//TODO: handle this error
return;
}
window.location.reload();
});
}
}]);
}
};
var buttons = document.getElementById('mapContainer').getElementsByClassName('deleteButton');
for (var i = 0; i < buttons.length; i++) {
var button = buttons[i];
button.onclick = function () {
Maps.deleteMap(this.dataset.mapId, this.dataset.mapName);
};
}
})();
-48
View File
@@ -1,48 +0,0 @@
(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 'password_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;
return;
}
document.getElementById('profileFormError').style.display = 'none';
form.reset();
form.elements.save.disabled = true;
}, formData);
};
})();
-60
View File
@@ -1,60 +0,0 @@
(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 () {
if (this.response.error) {
if (this.response.error === 'user_found') {
window.location.replace('/');
return;
}
var errorText;
switch (this.response.error) {
case 'email_not_valid':
errorText = 'The given email address is not valid.'
break;
case 'password_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_user_not_active':
errorText = 'There is a user already registered with the given email address. Please check your email and click on the activation link!';
break;
case 'user_found_password_not_match':
errorText = 'There is a user already registered with the given email address, but the given password is wrong.'
break;
}
document.getElementById('loading').style.visibility = 'hidden';
var signupFormError = document.getElementById('signupFormError');
signupFormError.style.display = 'block';
signupFormError.innerHTML = errorText;
return;
}
window.location.replace('/signup/success');
}, formData);
};
var resetSignupButton = document.getElementById('resetSignupButton');
if (resetSignupButton) {
resetSignupButton.onclick = function () {
document.getElementById('loading').style.visibility = 'visible';
MapGuesser.httpRequest('POST', '/signup/reset', function () {
window.location.reload();
});
};
}
})();
+4 -1
View File
@@ -16,11 +16,14 @@ echo "Installing MapGuesser DB..."
mysql --host=${DB_HOST} --user=${DB_USER} --password=${DB_PASSWORD} ${DB_NAME} < ${ROOT_DIR}/db/mapguesser.sql mysql --host=${DB_HOST} --user=${DB_USER} --password=${DB_PASSWORD} ${DB_NAME} < ${ROOT_DIR}/db/mapguesser.sql
echo "Migrating DB..." echo "Migrating DB..."
(cd ${ROOT_DIR} && ./mapg migrate) (cd ${ROOT_DIR} && ./mapg db:migrate)
if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then
echo "Minifying JS, CSS and SVG files..." echo "Minifying JS, CSS and SVG files..."
${ROOT_DIR}/scripts/minify.sh ${ROOT_DIR}/scripts/minify.sh
echo "Linking view files..."
(cd ${ROOT_DIR} && ./mapg view:link)
fi fi
touch ${ROOT_DIR}/installed touch ${ROOT_DIR}/installed
+9 -2
View File
@@ -5,15 +5,22 @@ ROOT_DIR=$(dirname $(readlink -f "$0"))/..
. ${ROOT_DIR}/.env . ${ROOT_DIR}/.env
echo "Installing Composer packages..." echo "Installing Composer packages..."
(cd ${ROOT_DIR} && composer install) if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then
(cd ${ROOT_DIR} && composer install --no-dev)
else
(cd ${ROOT_DIR} && composer install --dev)
fi
echo "Installing Yarn packages..." echo "Installing Yarn packages..."
(cd ${ROOT_DIR}/public/static && yarn install) (cd ${ROOT_DIR}/public/static && yarn install)
echo "Migrating DB..." echo "Migrating DB..."
(cd ${ROOT_DIR} && ./mapg migrate) (cd ${ROOT_DIR} && ./mapg db:migrate)
if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then if [ -z "${DEV}" ] || [ "${DEV}" -eq "0" ]; then
echo "Minifying JS, CSS and SVG files..." echo "Minifying JS, CSS and SVG files..."
${ROOT_DIR}/scripts/minify.sh ${ROOT_DIR}/scripts/minify.sh
echo "Linking view files..."
(cd ${ROOT_DIR} && ./mapg view:link)
fi fi
+4 -1
View File
@@ -1,5 +1,6 @@
<?php namespace MapGuesser\Cli; <?php namespace MapGuesser\Cli;
use DateTime;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\PersistentData\Model\User; use MapGuesser\PersistentData\Model\User;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@@ -9,7 +10,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class AddUserCommand extends Command class AddUserCommand extends Command
{ {
public function configure() public function configure(): void
{ {
$this->setName('user:add') $this->setName('user:add')
->setDescription('Adding of user.') ->setDescription('Adding of user.')
@@ -23,6 +24,8 @@ class AddUserCommand extends Command
$user = new User(); $user = new User();
$user->setEmail($input->getArgument('email')); $user->setEmail($input->getArgument('email'));
$user->setPlainPassword($input->getArgument('password')); $user->setPlainPassword($input->getArgument('password'));
$user->setActive(true);
$user->setCreatedDate(new DateTime());
if ($input->hasArgument('type')) { if ($input->hasArgument('type')) {
$user->setType($input->getArgument('type')); $user->setType($input->getArgument('type'));
+69
View File
@@ -0,0 +1,69 @@
<?php namespace MapGuesser\Cli;
use FilesystemIterator;
use MapGuesser\View\Linker;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class LinkViewCommand extends Command
{
public function configure(): void
{
$this->setName('view:link')
->setDescription('Linking of views.')
->addArgument('view', InputArgument::OPTIONAL, 'View file to be linked.');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$views = [];
$view = $input->getArgument('view');
if ($view !== null) {
$views[] = $view;
} else {
$folder = ROOT . '/views';
$folderLength = strlen($folder) + 1;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
foreach ($iterator as $file) {
if ($file->isDir() || $file->getExtension() !== 'php') {
continue;
}
$view = substr($file->getPath(), $folderLength) . '/' . $file->getBasename('.php');
if (strpos($view, 'templates') === 0 || strpos($view, 'tests') === 0) {
continue;
}
$views[] = $view;
}
}
try {
foreach ($views as $view) {
$generator = new Linker($view);
$generator->generate();
}
} catch (\Exception $e) {
$output->writeln('<error>Linking view(s) failed!</error>');
$output->writeln('');
$output->writeln((string) $e);
$output->writeln('');
return 1;
}
$output->writeln('<info>View(s) successfully linked!</info>');
return 0;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php namespace MapGuesser\Cli;
use DateTime;
use MapGuesser\Database\Query\Modify;
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Repository\UserConfirmationRepository;
use MapGuesser\Repository\UserPasswordResetterRepository;
use MapGuesser\Repository\UserRepository;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class MaintainDatabaseCommand extends Command
{
private PersistentDataManager $pdm;
private UserRepository $userRepository;
private UserConfirmationRepository $userConfirmationRepository;
private UserPasswordResetterRepository $userPasswordResetterRepository;
public function __construct()
{
parent::__construct();
$this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository();
$this->userConfirmationRepository = new UserConfirmationRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
}
public function configure(): void
{
$this->setName('db:maintain')
->setDescription('Maintain database.');
}
public function execute(InputInterface $input, OutputInterface $output): int
{
try {
$this->deleteInactiveExpiredUsers();
$this->deleteExpiredPasswordResetters();
$this->deleteExpiredSessions();
} catch (\Exception $e) {
$output->writeln('<error>Maintenance failed!</error>');
$output->writeln('');
$output->writeln((string) $e);
$output->writeln('');
return 1;
}
$output->writeln('<info>Maintenance was successful!</info>');
$output->writeln('');
return 0;
}
private function deleteInactiveExpiredUsers(): void
{
\Container::$dbConnection->startTransaction();
foreach ($this->userRepository->getAllInactiveExpired() as $user) {
//TODO: these can be in some wrapper class
$userConfirmation = $this->userConfirmationRepository->getByUser($user);
if ($userConfirmation !== null) {
$this->pdm->deleteFromDb($userConfirmation);
}
$userPasswordResetter = $this->userPasswordResetterRepository->getByUser($user);
if ($userPasswordResetter !== null) {
$this->pdm->deleteFromDb($userPasswordResetter);
}
$this->pdm->deleteFromDb($user);
}
\Container::$dbConnection->commit();
}
private function deleteExpiredPasswordResetters(): void
{
foreach ($this->userPasswordResetterRepository->getAllExpired() as $passwordResetter) {
$this->pdm->deleteFromDb($passwordResetter);
}
}
private function deleteExpiredSessions(): void
{
//TODO: model may be used for sessions too
$select = new Select(\Container::$dbConnection, 'sessions');
$select->columns(['id']);
$select->where('updated', '<', (new DateTime('-7 days'))->format('Y-m-d H:i:s'));
$result = $select->execute();
while ($session = $result->fetch(IResultSet::FETCH_ASSOC)) {
$modify = new Modify(\Container::$dbConnection, 'sessions');
$modify->setId($session['id']);
$modify->delete();
}
}
}
@@ -7,11 +7,11 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class DatabaseMigration extends Command class MigrateDatabaseCommand extends Command
{ {
public function configure() public function configure(): void
{ {
$this->setName('migrate') $this->setName('db:migrate')
->setDescription('Migration of database changes.'); ->setDescription('Migration of database changes.');
} }
@@ -88,6 +88,10 @@ class DatabaseMigration extends Command
$path = ROOT . '/database/migrations/' . $type; $path = ROOT . '/database/migrations/' . $type;
$dir = opendir($path); $dir = opendir($path);
if ($dir === false) {
throw new \Exception('Cannot open dir: ' . $path);
}
$files = []; $files = [];
while ($file = readdir($dir)) { while ($file = readdir($dir)) {
$filePath = $path . '/' . $file; $filePath = $path . '/' . $file;
+5 -5
View File
@@ -21,18 +21,18 @@ class GameController
public function getGame(): IContent public function getGame(): IContent
{ {
$mapId = (int) $this->request->query('mapId'); $mapId = (int) $this->request->query('mapId');
$data = $this->prepareGame($mapId);
return new HtmlContent('game', $data); return new HtmlContent('game', $this->prepareGame($mapId));
} }
public function getGameJson(): IContent public function getGameJson(): IContent
{ {
$mapId = (int) $this->request->query('mapId'); $mapId = (int) $this->request->query('mapId');
$data = $this->prepareGame($mapId);
return new JsonContent($data); return new JsonContent($this->prepareGame($mapId));
} }
private function prepareGame(int $mapId) private function prepareGame(int $mapId): array
{ {
$map = $this->mapRepository->getById($mapId); $map = $this->mapRepository->getById($mapId);
+19 -12
View File
@@ -29,8 +29,7 @@ class GameFlowController
$session = $this->request->session(); $session = $this->request->session();
if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) { if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) {
$data = ['error' => 'no_session_found']; return new JsonContent(['error' => 'no_session_found']);
return new JsonContent($data);
} }
if (count($state['rounds']) === 0) { if (count($state['rounds']) === 0) {
@@ -41,7 +40,10 @@ class GameFlowController
$session->set('state', $state); $session->set('state', $state);
$data = ['panoId' => $place->getPanoIdCached()]; $data = [
'panoId' => $place->getPanoIdCached(),
'pov' => $place->getPov()->toArray()
];
} else { } else {
$rounds = count($state['rounds']); $rounds = count($state['rounds']);
$last = $state['rounds'][$rounds - 1]; $last = $state['rounds'][$rounds - 1];
@@ -59,7 +61,10 @@ class GameFlowController
$data = [ $data = [
'history' => $history, 'history' => $history,
'panoId' => $last['panoId'] 'panoId' => $last['panoId'],
'pov' => isset($last['pov']) ? // should be checked not to break with old sessions
$last['pov']->toArray() :
['heading' => 0.0, 'pitch' => 0.0, 'zoom' => 0.0],
]; ];
} }
@@ -73,8 +78,7 @@ class GameFlowController
$session = $this->request->session(); $session = $this->request->session();
if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) { if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) {
$data = ['error' => 'no_session_found']; return new JsonContent(['error' => 'no_session_found']);
return new JsonContent($data);
} }
$last = $state['rounds'][count($state['rounds']) - 1]; $last = $state['rounds'][count($state['rounds']) - 1];
@@ -103,32 +107,35 @@ class GameFlowController
$this->addNewRoundToState($state, $place, $placesWithoutPano); $this->addNewRoundToState($state, $place, $placesWithoutPano);
$panoId = $place->getPanoIdCached(); $panoId = $place->getPanoIdCached();
$pov = $place->getPov()->toArray();
} else { } else {
$state['rounds'] = []; $state['rounds'] = [];
$panoId = null; $panoId = null;
$pov = null;
} }
$session->set('state', $state); $session->set('state', $state);
$data = [ return new JsonContent([
'result' => [ 'result' => [
'position' => $position->toArray(), 'position' => $position->toArray(),
'distance' => $distance, 'distance' => $distance,
'score' => $score 'score' => $score
], ],
'panoId' => $panoId 'panoId' => $panoId,
]; 'pov' => $pov
return new JsonContent($data); ]);
} }
private function addNewRoundToState(&$state, Place $place, array $placesWithoutPano): void private function addNewRoundToState(array &$state, Place $place, array $placesWithoutPano): void
{ {
$state['rounds'][] = [ $state['rounds'][] = [
'placesWithoutPano' => $placesWithoutPano, 'placesWithoutPano' => $placesWithoutPano,
'placeId' => $place->getId(), 'placeId' => $place->getId(),
'position' => $place->getPosition(), 'position' => $place->getPosition(),
'panoId' => $place->getPanoIdCached() 'panoId' => $place->getPanoIdCached(),
'pov' => $place->getPov()
]; ];
} }
+17
View File
@@ -1,12 +1,29 @@
<?php namespace MapGuesser\Controller; <?php namespace MapGuesser\Controller;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Interfaces\Response\IRedirect; use MapGuesser\Interfaces\Response\IRedirect;
use MapGuesser\Response\JsonContent;
use MapGuesser\Response\Redirect; use MapGuesser\Response\Redirect;
class HomeController class HomeController
{ {
private IRequest $request;
public function __construct(IRequest $request)
{
$this->request = $request;
}
public function getIndex(): IRedirect public function getIndex(): IRedirect
{ {
return new Redirect(\Container::$routeCollection->getRoute('maps')->generateLink(), IRedirect::TEMPORARY); return new Redirect(\Container::$routeCollection->getRoute('maps')->generateLink(), IRedirect::TEMPORARY);
} }
public function startSession(): IContent
{
// session starts with the request, this method just sends valid data to the client
return new JsonContent(['antiCsrfToken' => $this->request->session()->get('anti_csrf_token')]);
}
} }
+286 -70
View File
@@ -1,5 +1,8 @@
<?php namespace MapGuesser\Controller; <?php namespace MapGuesser\Controller;
use DateInterval;
use DateTime;
use MapGuesser\Http\Request;
use MapGuesser\Interfaces\Request\IRequest; use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent; use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Interfaces\Response\IRedirect; use MapGuesser\Interfaces\Response\IRedirect;
@@ -7,8 +10,10 @@ use MapGuesser\Mailing\Mail;
use MapGuesser\OAuth\GoogleOAuth; use MapGuesser\OAuth\GoogleOAuth;
use MapGuesser\PersistentData\Model\User; use MapGuesser\PersistentData\Model\User;
use MapGuesser\PersistentData\Model\UserConfirmation; use MapGuesser\PersistentData\Model\UserConfirmation;
use MapGuesser\PersistentData\Model\UserPasswordResetter;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Repository\UserConfirmationRepository; use MapGuesser\Repository\UserConfirmationRepository;
use MapGuesser\Repository\UserPasswordResetterRepository;
use MapGuesser\Repository\UserRepository; use MapGuesser\Repository\UserRepository;
use MapGuesser\Response\HtmlContent; use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent; use MapGuesser\Response\JsonContent;
@@ -25,12 +30,15 @@ class LoginController
private UserConfirmationRepository $userConfirmationRepository; private UserConfirmationRepository $userConfirmationRepository;
private UserPasswordResetterRepository $userPasswordResetterRepository;
public function __construct(IRequest $request) public function __construct(IRequest $request)
{ {
$this->request = $request; $this->request = $request;
$this->pdm = new PersistentDataManager(); $this->pdm = new PersistentDataManager();
$this->userRepository = new UserRepository(); $this->userRepository = new UserRepository();
$this->userConfirmationRepository = new UserConfirmationRepository(); $this->userConfirmationRepository = new UserConfirmationRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
} }
public function getLoginForm() public function getLoginForm()
@@ -39,18 +47,23 @@ class LoginController
return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY); return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
} }
$data = []; return new HtmlContent('login/login');
return new HtmlContent('login/login', $data);
} }
public function getGoogleLoginRedirect(): IRedirect public function getGoogleLoginRedirect(): IRedirect
{ {
$state = bin2hex(random_bytes(16)); $state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
$this->request->session()->set('oauth_state', $state); $this->request->session()->set('oauth_state', $state);
$this->request->session()->set('oauth_nonce', $nonce);
$oAuth = new GoogleOAuth(); $oAuth = new GoogleOAuth(new Request());
$url = $oAuth->getDialogUrl($state, $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('login-google-action')->generateLink()); $url = $oAuth->getDialogUrl(
$state,
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('login-google-action')->generateLink(),
$nonce
);
return new Redirect($url, IRedirect::TEMPORARY); return new Redirect($url, IRedirect::TEMPORARY);
} }
@@ -74,8 +87,7 @@ class LoginController
public function getSignupSuccess(): IContent public function getSignupSuccess(): IContent
{ {
$data = []; return new HtmlContent('login/signup_success');
return new HtmlContent('login/signup_success', $data);
} }
public function getSignupWithGoogleForm() public function getSignupWithGoogleForm()
@@ -92,48 +104,96 @@ class LoginController
$user = $this->userRepository->getByEmail($userData['email']); $user = $this->userRepository->getByEmail($userData['email']);
$data = ['found' => $user !== null, 'email' => $userData['email']]; return new HtmlContent('login/google_signup', ['found' => $user !== null, 'email' => $userData['email']]);
return new HtmlContent('login/google_signup', $data); }
public function getRequestPasswordResetForm()
{
if ($this->request->user() !== null) {
return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
}
return new HtmlContent('login/password_reset_request', ['email' => $this->request->query('email')]);
}
public function getRequestPasswordResetSuccess(): IContent
{
return new HtmlContent('login/password_reset_request_success');
}
public function getResetPasswordForm()
{
if ($this->request->user() !== null) {
return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
}
$token = $this->request->query('token');
$resetter = $this->userPasswordResetterRepository->getByToken($token);
if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) {
return new HtmlContent('login/reset_password', ['success' => false]);
}
$user = $this->userRepository->getById($resetter->getUserId());
return new HtmlContent('login/reset_password', ['success' => true, 'token' => $token, 'email' => $user->getEmail()]);
} }
public function login(): IContent public function login(): IContent
{ {
if ($this->request->user() !== null) { if ($this->request->user() !== null) {
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
$user = $this->userRepository->getByEmail($this->request->post('email')); $user = $this->userRepository->getByEmail($this->request->post('email'));
if ($user === null) { if ($user === null) {
if (strlen($this->request->post('password')) < 6) { if (strlen($this->request->post('password')) < 6) {
$data = ['error' => 'password_too_short']; return new JsonContent([
return new JsonContent($data); 'error' => [
'errorText' => 'The given password is too short. Please choose a password that is at least 6 characters long!'
]
]);
} }
$tmpUser = new User(); $tmpUser = new User();
$tmpUser->setPlainPassword($this->request->post('password')); $tmpUser->setPlainPassword($this->request->post('password'));
$this->request->session()->set('tmp_user_data', ['email' => $this->request->post('email'), 'password_hashed' => $tmpUser->getPassword()]); $this->request->session()->set('tmp_user_data', [
'email' => $this->request->post('email'),
'password_hashed' => $tmpUser->getPassword()
]);
$data = ['error' => 'user_not_found']; return new JsonContent([
return new JsonContent($data); 'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('signup')->generateLink()
]
]);
} }
if (!$user->getActive()) { if (!$user->getActive()) {
$data = ['error' => 'user_not_active']; $this->resendConfirmationEmail($user);
return new JsonContent($data);
return new JsonContent([
'error' => [
'errorText' => 'User found with the given email address, but the account is not activated. ' .
'Please check your email and click on the activation link!'
]
]);
} }
if (!$user->checkPassword($this->request->post('password'))) { if (!$user->checkPassword($this->request->post('password'))) {
$data = ['error' => 'password_not_match']; return new JsonContent([
return new JsonContent($data); 'error' => [
'errorText' => 'The given password is wrong. You can <a href="/password/requestReset?email=' .
urlencode($user->getEmail()) . '" title="Request password reset">request password reset</a>!'
]
]);
} }
$this->request->setUser($user); $this->request->setUser($user);
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
public function loginWithGoogle() public function loginWithGoogle()
@@ -143,30 +203,34 @@ class LoginController
} }
if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) { if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) {
$data = []; return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login', $data);
} }
$oAuth = new GoogleOAuth(); $oAuth = new GoogleOAuth(new Request());
$tokenData = $oAuth->getToken($this->request->query('code'), $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('login-google-action')->generateLink()); $tokenData = $oAuth->getToken(
$this->request->query('code'),
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('login-google-action')->generateLink()
);
if (!isset($tokenData['id_token'])) { if (!isset($tokenData['id_token'])) {
$data = []; return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login', $data);
} }
$jwtParser = new JwtParser($tokenData['id_token']); $jwtParser = new JwtParser($tokenData['id_token']);
$userData = $jwtParser->getPayload(); $idToken = $jwtParser->getPayload();
if (!$userData['email_verified']) { if ($idToken['nonce'] !== $this->request->session()->get('oauth_nonce')) {
$data = []; return new HtmlContent('login/google_login');
return new HtmlContent('login/google_login', $data);
} }
$user = $this->userRepository->getByGoogleSub($userData['sub']); if (!$idToken['email_verified']) {
return new HtmlContent('login/google_login');
}
$user = $this->userRepository->getByGoogleSub($idToken['sub']);
if ($user === null) { if ($user === null) {
$this->request->session()->set('google_user_data', $userData); $this->request->session()->set('google_user_data', ['sub' => $idToken['sub'], 'email' => $idToken['email']]);
return new Redirect(\Container::$routeCollection->getRoute('signup-google')->generateLink(), IRedirect::TEMPORARY); return new Redirect(\Container::$routeCollection->getRoute('signup-google')->generateLink(), IRedirect::TEMPORARY);
} }
@@ -186,8 +250,7 @@ class LoginController
public function signup(): IContent public function signup(): IContent
{ {
if ($this->request->user() !== null) { if ($this->request->user() !== null) {
$data = ['error' => 'logged_in']; return new JsonContent(['redirect' => ['target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()]]);
return new JsonContent($data);
} }
$user = $this->userRepository->getByEmail($this->request->post('email')); $user = $this->userRepository->getByEmail($this->request->post('email'));
@@ -195,22 +258,31 @@ class LoginController
if ($user !== null) { if ($user !== null) {
if ($user->getActive()) { if ($user->getActive()) {
if (!$user->checkPassword($this->request->post('password'))) { if (!$user->checkPassword($this->request->post('password'))) {
$data = ['error' => 'user_found_password_not_match']; return new JsonContent([
return new JsonContent($data); 'error' => [
'errorText' => 'There is a user already registered with the given email address, ' .
'but the given password is wrong. You can <a href="/password/requestReset?email=' .
urlencode($user->getEmail()) . '" title="Request password reset">request password reset</a>!'
]
]);
} }
$this->request->setUser($user); $this->request->setUser($user);
$data = ['error' => 'user_found']; $data = ['redirect' => ['target' => '/' . \Container::$routeCollection->getRoute('index')->generateLink()]];
} else { } else {
$data = ['error' => 'user_found_user_not_active']; $data = [
'error' => [
'errorText' => 'There is a user already registered with the given email address. ' .
'Please check your email and click on the activation link!'
]
];
} }
return new JsonContent($data); return new JsonContent($data);
} }
if (filter_var($this->request->post('email'), FILTER_VALIDATE_EMAIL) === false) { if (filter_var($this->request->post('email'), FILTER_VALIDATE_EMAIL) === false) {
$data = ['error' => 'email_not_valid']; return new JsonContent(['error' => ['errorText' => 'The given email address is not valid.']]);
return new JsonContent($data);
} }
if ($this->request->session()->has('tmp_user_data')) { if ($this->request->session()->has('tmp_user_data')) {
@@ -220,52 +292,53 @@ class LoginController
$tmpUser->setPassword($tmpUserData['password_hashed']); $tmpUser->setPassword($tmpUserData['password_hashed']);
if (!$tmpUser->checkPassword($this->request->post('password'))) { if (!$tmpUser->checkPassword($this->request->post('password'))) {
$data = ['error' => 'passwords_not_match']; return new JsonContent(['error' => ['errorText' => 'The given passwords do not match.']]);
return new JsonContent($data);
} }
} else { } else {
if (strlen($this->request->post('password')) < 6) { if (strlen($this->request->post('password')) < 6) {
$data = ['error' => 'password_too_short']; return new JsonContent([
return new JsonContent($data); 'error' => [
'errorText' => 'The given password is too short. Please choose a password that is at least 6 characters long!'
]
]);
} }
if ($this->request->post('password') !== $this->request->post('password_confirm')) { if ($this->request->post('password') !== $this->request->post('password_confirm')) {
$data = ['error' => 'passwords_not_match']; return new JsonContent(['error' => ['errorText' => 'The given passwords do not match.']]);
return new JsonContent($data);
} }
} }
$user = new User(); $user = new User();
$user->setEmail($this->request->post('email')); $user->setEmail($this->request->post('email'));
$user->setPlainPassword($this->request->post('password')); $user->setPlainPassword($this->request->post('password'));
$user->setCreatedDate(new DateTime());
\Container::$dbConnection->startTransaction(); \Container::$dbConnection->startTransaction();
$this->pdm->saveToDb($user); $this->pdm->saveToDb($user);
$token = hash('sha256', serialize($user) . random_bytes(10) . microtime()); $token = bin2hex(random_bytes(16));
$confirmation = new UserConfirmation(); $confirmation = new UserConfirmation();
$confirmation->setUser($user); $confirmation->setUser($user);
$confirmation->setToken($token); $confirmation->setToken($token);
$confirmation->setLastSentDate(new DateTime());
$this->pdm->saveToDb($confirmation); $this->pdm->saveToDb($confirmation);
\Container::$dbConnection->commit(); \Container::$dbConnection->commit();
$this->sendConfirmationEmail($user->getEmail(), $token); $this->sendConfirmationEmail($user->getEmail(), $token, $user->getCreatedDate());
$this->request->session()->delete('tmp_user_data'); $this->request->session()->delete('tmp_user_data');
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
public function signupWithGoogle(): IContent public function signupWithGoogle(): IContent
{ {
if ($this->request->user() !== null) { if ($this->request->user() !== null) {
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
$userData = $this->request->session()->get('google_user_data'); $userData = $this->request->session()->get('google_user_data');
@@ -277,6 +350,7 @@ class LoginController
$user = new User(); $user = new User();
$user->setEmail($userData['email']); $user->setEmail($userData['email']);
$user->setCreatedDate(new DateTime());
} else { } else {
$sendWelcomeEmail = false; $sendWelcomeEmail = false;
} }
@@ -293,24 +367,21 @@ class LoginController
$this->request->session()->delete('google_user_data'); $this->request->session()->delete('google_user_data');
$this->request->setUser($user); $this->request->setUser($user);
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
public function resetSignup(): IContent public function resetSignup(): IContent
{ {
$this->request->session()->delete('tmp_user_data'); $this->request->session()->delete('tmp_user_data');
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
public function resetGoogleSignup(): IContent public function resetGoogleSignup(): IContent
{ {
$this->request->session()->delete('google_user_data'); $this->request->session()->delete('google_user_data');
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
public function activate() public function activate()
@@ -319,11 +390,10 @@ class LoginController
return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY); return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
} }
$confirmation = $this->userConfirmationRepository->getByToken($this->request->query('token')); $confirmation = $this->userConfirmationRepository->getByToken(substr($this->request->query('token'), 0, 32));
if ($confirmation === null) { if ($confirmation === null) {
$data = []; return new HtmlContent('login/activate');
return new HtmlContent('login/activate', $data);
} }
\Container::$dbConnection->startTransaction(); \Container::$dbConnection->startTransaction();
@@ -348,11 +418,10 @@ class LoginController
return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY); return new Redirect(\Container::$routeCollection->getRoute('index')->generateLink(), IRedirect::TEMPORARY);
} }
$confirmation = $this->userConfirmationRepository->getByToken($this->request->query('token')); $confirmation = $this->userConfirmationRepository->getByToken(substr($this->request->query('token'), 0, 32));
if ($confirmation === null) { if ($confirmation === null) {
$data = ['success' => false]; return new HtmlContent('login/cancel', ['success' => false]);
return new HtmlContent('login/cancel', $data);
} }
\Container::$dbConnection->startTransaction(); \Container::$dbConnection->startTransaction();
@@ -365,23 +434,156 @@ class LoginController
\Container::$dbConnection->commit(); \Container::$dbConnection->commit();
$data = ['success' => true]; return new HtmlContent('login/cancel', ['success' => true]);
return new HtmlContent('login/cancel', $data);
} }
private function sendConfirmationEmail(string $email, string $token): void public function requestPasswordReset(): IContent
{
if ($this->request->user() !== null) {
return new JsonContent([
'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()
]
]);
}
$user = $this->userRepository->getByEmail($this->request->post('email'));
if ($user === null) {
return new JsonContent([
'error' => [
'errorText' => 'No user found with the given email address. You can <a href="/signup" title="Sign up">sign up</a>!'
]
]);
}
if (!$user->getActive()) {
$this->resendConfirmationEmail($user);
return new JsonContent([
'error' => [
'errorText' => 'User found with the given email address, but the account is not activated. ' .
'Please check your email and click on the activation link!'
]
]);
}
$existingResetter = $this->userPasswordResetterRepository->getByUser($user);
if ($existingResetter !== null && $existingResetter->getExpiresDate() > new DateTime()) {
return new JsonContent([
'error' => [
'errorText' => 'Password reset was recently requested for this account. Please check your email, or try again later!'
]
]);
}
$token = bin2hex(random_bytes(16));
$expires = new DateTime('+1 hour');
$passwordResetter = new UserPasswordResetter();
$passwordResetter->setUser($user);
$passwordResetter->setToken($token);
$passwordResetter->setExpiresDate($expires);
\Container::$dbConnection->startTransaction();
if ($existingResetter !== null) {
$this->pdm->deleteFromDb($existingResetter);
}
$this->pdm->saveToDb($passwordResetter);
\Container::$dbConnection->commit();
$this->sendPasswordResetEmail($user->getEmail(), $token, $expires);
return new JsonContent(['success' => true]);
}
public function resetPassword(): IContent
{
if ($this->request->user() !== null) {
return new JsonContent([
'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('home')->generateLink()
]
]);
}
$token = $this->request->query('token');
$resetter = $this->userPasswordResetterRepository->getByToken($token);
if ($resetter === null || $resetter->getExpiresDate() < new DateTime()) {
return new JsonContent([
'redirect' => [
'target' => '/' . \Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token])
]
]);
}
if (strlen($this->request->post('password')) < 6) {
return new JsonContent([
'error' => [
'errorText' => 'The given password is too short. Please choose a password that is at least 6 characters long!'
]
]);
}
if ($this->request->post('password') !== $this->request->post('password_confirm')) {
return new JsonContent(['error' => ['errorText' => 'The given passwords do not match.']]);
}
\Container::$dbConnection->startTransaction();
$this->pdm->deleteFromDb($resetter);
$user = $this->userRepository->getById($resetter->getUserId());
$user->setPlainPassword($this->request->post('password'));
$this->pdm->saveToDb($user);
\Container::$dbConnection->commit();
$this->request->setUser($user);
return new JsonContent(['success' => true]);
}
private function sendConfirmationEmail(string $email, string $token, DateTime $created): void
{ {
$mail = new Mail(); $mail = new Mail();
$mail->addRecipient($email); $mail->addRecipient($email);
$mail->setSubject('Welcome to ' . $_ENV['APP_NAME'] . ' - Activate your account'); $mail->setSubject('Welcome to ' . $_ENV['APP_NAME'] . ' - Activate your account');
$mail->setBodyFromTemplate('signup', [ $mail->setBodyFromTemplate('signup', [
'EMAIL' => $email, 'EMAIL' => $email,
'ACTIVATE_LINK' => $this->request->getBase() . '/'. \Container::$routeCollection->getRoute('signup.activate')->generateLink(['token' => $token]), 'ACTIVATE_LINK' => $this->request->getBase() . '/' .
'CANCEL_LINK' => $this->request->getBase() . '/' . \Container::$routeCollection->getRoute('signup.cancel')->generateLink(['token' => $token]), \Container::$routeCollection->getRoute('signup.activate')->generateLink(['token' => $token]),
'CANCEL_LINK' => $this->request->getBase() . '/' .
\Container::$routeCollection->getRoute('signup.cancel')->generateLink(['token' => $token]),
'ACTIVATABLE_UNTIL' => (clone $created)->add(new DateInterval('P1D'))->format('Y-m-d H:i T')
]); ]);
$mail->send(); $mail->send();
} }
private function resendConfirmationEmail(User $user): bool
{
$confirmation = $this->userConfirmationRepository->getByUser($user);
if ($confirmation === null || (clone $confirmation->getLastSentDate())->add(new DateInterval('PT1H')) > new DateTime()) {
return false;
}
$confirmation->setLastSentDate(new DateTime());
$this->pdm->saveToDb($confirmation);
$this->sendConfirmationEmail($user->getEmail(), $confirmation->getToken(), $user->getCreatedDate());
return true;
}
private function sendWelcomeEmail(string $email): void private function sendWelcomeEmail(string $email): void
{ {
$mail = new Mail(); $mail = new Mail();
@@ -392,4 +594,18 @@ class LoginController
]); ]);
$mail->send(); $mail->send();
} }
private function sendPasswordResetEmail(string $email, string $token, DateTime $expires): void
{
$mail = new Mail();
$mail->addRecipient($email);
$mail->setSubject($_ENV['APP_NAME'] . ' - Password reset');
$mail->setBodyFromTemplate('password-reset', [
'EMAIL' => $email,
'RESET_LINK' => $this->request->getBase() . '/' .
\Container::$routeCollection->getRoute('password-reset')->generateLink(['token' => $token]),
'EXPIRES' => $expires->format('Y-m-d H:i T')
]);
$mail->send();
}
} }
+36 -49
View File
@@ -1,10 +1,8 @@
<?php namespace MapGuesser\Controller; <?php namespace MapGuesser\Controller;
use DateTime; use DateTime;
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Authentication\IUser; use MapGuesser\Interfaces\Authentication\IUser;
use MapGuesser\Interfaces\Authorization\ISecured; use MapGuesser\Interfaces\Authorization\ISecured;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Request\IRequest; use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent; use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\PersistentData\Model\Map; use MapGuesser\PersistentData\Model\Map;
@@ -15,7 +13,7 @@ use MapGuesser\Repository\PlaceRepository;
use MapGuesser\Response\HtmlContent; use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent; use MapGuesser\Response\JsonContent;
use MapGuesser\Util\Geo\Bounds; use MapGuesser\Util\Geo\Bounds;
use MapGuesser\Util\Geo\Position; use MapGuesser\Util\Panorama\Pov;
class MapAdminController implements ISecured class MapAdminController implements ISecured
{ {
@@ -57,8 +55,13 @@ class MapAdminController implements ISecured
$places = []; $places = [];
} }
$data = ['mapId' => $mapId, 'mapName' => $map->getName(), 'mapDescription' => str_replace('<br>', "\n", $map->getDescription()), 'bounds' => $map->getBounds()->toArray(), 'places' => &$places]; return new HtmlContent('admin/map_editor', [
return new HtmlContent('admin/map_editor', $data); 'mapId' => $mapId,
'mapName' => $map->getName(),
'mapDescription' => str_replace('<br>', "\n", $map->getDescription()),
'bounds' => $map->getBounds()->toArray(),
'places' => &$places
]);
} }
public function getPlace(): IContent public function getPlace(): IContent
@@ -67,8 +70,7 @@ class MapAdminController implements ISecured
$place = $this->placeRepository->getById($placeId); $place = $this->placeRepository->getById($placeId);
$data = ['panoId' => $place->getFreshPanoId()]; return new JsonContent(['panoId' => $place->getFreshPanoId()]);
return new JsonContent($data);
} }
public function saveMap(): IContent public function saveMap(): IContent
@@ -94,6 +96,11 @@ class MapAdminController implements ISecured
$place->setMap($map); $place->setMap($map);
$place->setLat((float) $placeRaw['lat']); $place->setLat((float) $placeRaw['lat']);
$place->setLng((float) $placeRaw['lng']); $place->setLng((float) $placeRaw['lng']);
$place->setPov(new Pov(
(float) $placeRaw['pov']['heading'],
(float) $placeRaw['pov']['pitch'],
(float) $placeRaw['pov']['zoom']
));
if ($placeRaw['panoId'] === -1) { if ($placeRaw['panoId'] === -1) {
$place->setPanoIdCachedTimestampDate(new DateTime('-1 day')); $place->setPanoIdCachedTimestampDate(new DateTime('-1 day'));
@@ -114,6 +121,11 @@ class MapAdminController implements ISecured
$place = $this->placeRepository->getById((int) $placeRaw['id']); $place = $this->placeRepository->getById((int) $placeRaw['id']);
$place->setLat((float) $placeRaw['lat']); $place->setLat((float) $placeRaw['lat']);
$place->setLng((float) $placeRaw['lng']); $place->setLng((float) $placeRaw['lng']);
$place->setPov(new Pov(
(float) $placeRaw['pov']['heading'],
(float) $placeRaw['pov']['pitch'],
(float) $placeRaw['pov']['zoom']
));
$place->setPanoIdCachedTimestampDate(new DateTime('-1 day')); $place->setPanoIdCachedTimestampDate(new DateTime('-1 day'));
$this->pdm->saveToDb($place); $this->pdm->saveToDb($place);
@@ -146,11 +158,11 @@ class MapAdminController implements ISecured
\Container::$dbConnection->commit(); \Container::$dbConnection->commit();
$data = ['mapId' => $map->getId(), 'added' => $addedIds]; return new JsonContent(['mapId' => $map->getId(), 'added' => $addedIds]);
return new JsonContent($data);
} }
public function deleteMap() { public function deleteMap(): IContent
{
$mapId = (int) $this->request->query('mapId'); $mapId = (int) $this->request->query('mapId');
$map = $this->mapRepository->getById($mapId); $map = $this->mapRepository->getById($mapId);
@@ -163,40 +175,22 @@ class MapAdminController implements ISecured
\Container::$dbConnection->commit(); \Container::$dbConnection->commit();
$data = ['success' => true]; return new JsonContent(['success' => true]);
return new JsonContent($data);
} }
private function deletePlaces(Map $map): void private function deletePlaces(Map $map): void
{ {
//TODO: relations? foreach ($this->placeRepository->getAllForMap($map) as $place) {
$select = new Select(\Container::$dbConnection, 'places');
$select->columns(Place::getFields());
$select->where('map_id', '=', $map->getId());
$result = $select->execute();
while ($placeData = $result->fetch(IResultSet::FETCH_ASSOC)) {
$place = new Place();
$this->pdm->fillWithData($placeData, $place);
$this->pdm->deleteFromDb($place); $this->pdm->deleteFromDb($place);
} }
} }
private function calculateMapBounds(Map $map): Bounds private function calculateMapBounds(Map $map): Bounds
{ {
//TODO: from repository or relations
$select = new Select(\Container::$dbConnection, 'places');
$select->columns(['lat', 'lng']);
$select->where('map_id', '=', $map->getId());
$result = $select->execute();
$bounds = new Bounds(); $bounds = new Bounds();
while ($place = $result->fetch(IResultSet::FETCH_ASSOC)) {
$bounds->extend(new Position($place['lat'], $place['lng'])); foreach ($this->placeRepository->getAllForMap($map) as $place) {
$bounds->extend($place->getPosition());
} }
return $bounds; return $bounds;
@@ -204,26 +198,19 @@ class MapAdminController implements ISecured
private function &getPlaces(Map $map): array private function &getPlaces(Map $map): array
{ {
//TODO: from repository or relations
$select = new Select(\Container::$dbConnection, 'places');
$select->columns(['id', 'lat', 'lng', 'pano_id_cached', 'pano_id_cached_timestamp']);
$select->where('map_id', '=', $map->getId());
$result = $select->execute();
$places = []; $places = [];
while ($place = $result->fetch(IResultSet::FETCH_ASSOC)) { foreach ($this->placeRepository->getAllForMap($map) as $place) {
//$panoId = ??? $noPano = $place->getPanoIdCachedTimestampDate() !== null && $place->getPanoIdCached() === null;
//$pov = ???
$noPano = $place['pano_id_cached_timestamp'] && $place['pano_id_cached'] === null;
$places[$place['id']] = [ $placeId = $place->getId();
'id' => $place['id'],
'lat' => $place['lat'], $places[$placeId] = [
'lng' => $place['lng'], 'id' => $placeId,
'lat' => $place->getLat(),
'lng' => $place->getLng(),
'panoId' => null, 'panoId' => null,
'pov' => ['heading' => 0.0, 'pitch' => 0.0, 'zoom' => 0.0], 'pov' => $place->getPov()->toArray(),
'noPano' => $noPano 'noPano' => $noPano
]; ];
} }
+5 -3
View File
@@ -19,7 +19,7 @@ class MapsController
public function getMaps(): IContent public function getMaps(): IContent
{ {
//TODO: from repository //TODO: from repository - count should be added
$select = new Select(\Container::$dbConnection, 'maps'); $select = new Select(\Container::$dbConnection, 'maps');
$select->columns([ $select->columns([
['maps', 'id'], ['maps', 'id'],
@@ -46,8 +46,10 @@ class MapsController
} }
$user = $this->request->user(); $user = $this->request->user();
$data = ['maps' => $maps, 'isAdmin' => $user !== null && $user->hasPermission(IUser::PERMISSION_ADMIN)]; return new HtmlContent('maps', [
return new HtmlContent('maps', $data); 'maps' => $maps,
'isAdmin' => $user !== null && $user->hasPermission(IUser::PERMISSION_ADMIN)
]);
} }
private function formatMapAreaForHuman(float $area): array private function formatMapAreaForHuman(float $area): array
+172 -13
View File
@@ -1,12 +1,20 @@
<?php namespace MapGuesser\Controller; <?php namespace MapGuesser\Controller;
use DateTime;
use MapGuesser\Http\Request;
use MapGuesser\Interfaces\Authorization\ISecured; use MapGuesser\Interfaces\Authorization\ISecured;
use MapGuesser\Interfaces\Request\IRequest; use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent; use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Interfaces\Response\IRedirect;
use MapGuesser\OAuth\GoogleOAuth;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\PersistentData\Model\User; use MapGuesser\PersistentData\Model\User;
use MapGuesser\Repository\UserConfirmationRepository;
use MapGuesser\Repository\UserPasswordResetterRepository;
use MapGuesser\Response\HtmlContent; use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent; use MapGuesser\Response\JsonContent;
use MapGuesser\Response\Redirect;
use MapGuesser\Util\JwtParser;
class UserController implements ISecured class UserController implements ISecured
{ {
@@ -14,10 +22,16 @@ class UserController implements ISecured
private PersistentDataManager $pdm; private PersistentDataManager $pdm;
private UserConfirmationRepository $userConfirmationRepository;
private UserPasswordResetterRepository $userPasswordResetterRepository;
public function __construct(IRequest $request) public function __construct(IRequest $request)
{ {
$this->request = $request; $this->request = $request;
$this->pdm = new PersistentDataManager(); $this->pdm = new PersistentDataManager();
$this->userConfirmationRepository = new UserConfirmationRepository();
$this->userPasswordResetterRepository = new UserPasswordResetterRepository();
} }
public function authorize(): bool public function authorize(): bool
@@ -27,38 +41,126 @@ class UserController implements ISecured
return $user !== null; return $user !== null;
} }
public function getProfile(): IContent public function getAccount(): IContent
{ {
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = $this->request->user();
$data = ['user' => $user->toArray()]; return new HtmlContent('account/account', ['user' => $user->toArray()]);
return new HtmlContent('profile', $data);
} }
public function saveProfile(): IContent public function getGoogleAuthenticateRedirect(): IRedirect
{ {
/** /**
* @var User $user * @var User $user
*/ */
$user = $this->request->user(); $user = $this->request->user();
if (!$user->checkPassword($this->request->post('password'))) { $state = bin2hex(random_bytes(16));
$data = ['error' => 'password_not_match']; $nonce = bin2hex(random_bytes(16));
return new JsonContent($data);
$this->request->session()->set('oauth_state', $state);
$this->request->session()->set('oauth_nonce', $nonce);
$oAuth = new GoogleOAuth(new Request());
$url = $oAuth->getDialogUrl(
$state,
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('account.googleAuthenticate-action')->generateLink(),
$nonce,
$user->getEmail()
);
return new Redirect($url, IRedirect::TEMPORARY);
}
public function authenticateWithGoogle(): IContent
{
/**
* @var User $user
*/
$user = $this->request->user();
if ($this->request->query('state') !== $this->request->session()->get('oauth_state')) {
return new HtmlContent('account/google_authenticate', ['success' => false]);
}
$oAuth = new GoogleOAuth(new Request());
$tokenData = $oAuth->getToken(
$this->request->query('code'),
$this->request->getBase() . '/' . \Container::$routeCollection->getRoute('account.googleAuthenticate-action')->generateLink()
);
if (!isset($tokenData['id_token'])) {
return new HtmlContent('account/google_authenticate', ['success' => false]);
}
$jwtParser = new JwtParser($tokenData['id_token']);
$idToken = $jwtParser->getPayload();
if ($idToken['nonce'] !== $this->request->session()->get('oauth_nonce')) {
return new HtmlContent('account/google_authenticate', ['success' => false]);
}
if ($idToken['sub'] !== $user->getGoogleSub()) {
return new HtmlContent('account/google_authenticate', [
'success' => false,
'errorText' => 'This Google account is not linked to your account.'
]);
}
$authenticatedWithGoogleUntil = new DateTime('+45 seconds');
$this->request->session()->set('authenticated_with_google_until', $authenticatedWithGoogleUntil);
return new HtmlContent('account/google_authenticate', [
'success' => true,
'authenticatedWithGoogleUntil' => $authenticatedWithGoogleUntil
]);
}
public function getDeleteAccount(): IContent
{
/**
* @var User $user
*/
$user = $this->request->user();
return new HtmlContent('account/delete', ['user' => $user->toArray()]);
}
public function saveAccount(): IContent
{
/**
* @var User $user
*/
$user = $this->request->user();
if (!$this->confirmUserIdentity(
$user,
$this->request->session()->get('authenticated_with_google_until'),
$this->request->post('password'),
$error
)) {
return new JsonContent(['error' => ['errorText' => $error]]);
} }
if (strlen($this->request->post('password_new')) > 0) { if (strlen($this->request->post('password_new')) > 0) {
if (strlen($this->request->post('password_new')) < 6) { if (strlen($this->request->post('password_new')) < 6) {
$data = ['error' => 'password_too_short']; return new JsonContent([
return new JsonContent($data); 'error' => [
'errorText' => 'The given new password is too short. Please choose a password that is at least 6 characters long!'
]
]);
} }
if ($this->request->post('password_new') !== $this->request->post('password_new_confirm')) { if ($this->request->post('password_new') !== $this->request->post('password_new_confirm')) {
$data = ['error' => 'passwords_not_match']; return new JsonContent([
return new JsonContent($data); 'error' => [
'errorText' => 'The given new passwords do not match.'
]
]);
} }
$user->setPlainPassword($this->request->post('password_new')); $user->setPlainPassword($this->request->post('password_new'));
@@ -66,7 +168,64 @@ class UserController implements ISecured
$this->pdm->saveToDb($user); $this->pdm->saveToDb($user);
$data = ['success' => true]; $this->request->session()->delete('authenticated_with_google_until');
return new JsonContent($data);
return new JsonContent(['success' => true]);
}
public function deleteAccount(): IContent
{
/**
* @var User $user
*/
$user = $this->request->user();
if (!$this->confirmUserIdentity(
$user,
$this->request->session()->get('authenticated_with_google_until'),
$this->request->post('password'),
$error
)) {
return new JsonContent(['error' => ['errorText' => $error]]);
}
\Container::$dbConnection->startTransaction();
$userConfirmation = $this->userConfirmationRepository->getByUser($user);
if ($userConfirmation !== null) {
$this->pdm->deleteFromDb($userConfirmation);
}
$userPasswordResetter = $this->userPasswordResetterRepository->getByUser($user);
if ($userPasswordResetter !== null) {
$this->pdm->deleteFromDb($userPasswordResetter);
}
$this->pdm->deleteFromDb($user);
\Container::$dbConnection->commit();
$this->request->session()->delete('authenticated_with_google_until');
return new JsonContent(['success' => true]);
}
private function confirmUserIdentity(User $user, ?DateTime $authenticatedWithGoogleUntil, ?string $password, string &$error): bool
{
if ($authenticatedWithGoogleUntil !== null && $authenticatedWithGoogleUntil > new DateTime()) {
return true;
}
if ($password !== null) {
if ($user->checkPassword($password)) {
return true;
}
$error = 'The given current password is wrong.';
return false;
}
$error = 'Could not confirm your identity. Please try again!';
return false;
} }
} }
+2 -2
View File
@@ -12,11 +12,11 @@ class Connection implements IConnection
public function __construct(string $host, string $user, string $password, string $db, int $port = -1, string $socket = null) public function __construct(string $host, string $user, string $password, string $db, int $port = -1, string $socket = null)
{ {
if ($port < 0) { if ($port < 0) {
$port = ini_get('mysqli.default_port'); $port = (int) ini_get('mysqli.default_port');
} }
if ($socket === null) { if ($socket === null) {
$socket = ini_get('mysqli.default_socket'); $socket = (string) ini_get('mysqli.default_socket');
} }
$this->connection = new mysqli($host, $user, $password, $db, $port, $socket); $this->connection = new mysqli($host, $user, $password, $db, $port, $socket);
+1 -1
View File
@@ -135,6 +135,6 @@ class Modify
private function generateKey(): string private function generateKey(): string
{ {
return substr(hash('sha256', serialize($this->attributes) . random_bytes(10) . microtime()), 0, 7); return substr(hash('sha256', serialize($this->attributes) . random_bytes(5) . microtime()), 0, 7);
} }
} }
+5 -5
View File
@@ -30,7 +30,7 @@ class Select
private array $orders = []; private array $orders = [];
private array $limit; private ?array $limit;
public function __construct(IConnection $connection, ?string $table = null) public function __construct(IConnection $connection, ?string $table = null)
{ {
@@ -144,7 +144,7 @@ class Select
$this->limit = null; $this->limit = null;
} }
public function paginate(int $page, int $itemsPerPage) public function paginate(int $page, int $itemsPerPage): Select
{ {
$this->limit($itemsPerPage, ($page - 1) * $itemsPerPage); $this->limit($itemsPerPage, ($page - 1) * $itemsPerPage);
@@ -308,7 +308,7 @@ class Select
return implode(' ', $joins); return implode(' ', $joins);
} }
private function generateConditions(string $type): array private function generateConditions(int $type): array
{ {
$conditions = ''; $conditions = '';
$params = []; $params = [];
@@ -376,9 +376,9 @@ class Select
return [$conditionsString, $params]; return [$conditionsString, $params];
} }
private function generateComplexConditionFragment(string $type, Closure $conditionCallback): array private function generateComplexConditionFragment(int $type, Closure $conditionCallback): array
{ {
$instance = new static($this->connection, $this->table); $instance = new self($this->connection, $this->table);
$instance->tableAliases = $this->tableAliases; $instance->tableAliases = $this->tableAliases;
$conditionCallback($instance); $conditionCallback($instance);
+2 -1
View File
@@ -1,7 +1,8 @@
<?php namespace MapGuesser\Database; <?php namespace MapGuesser\Database;
class Utils { class Utils {
public static function backtick(string $name) { public static function backtick(string $name): string
{
return '`' . $name . '`'; return '`' . $name . '`';
} }
} }
+22 -13
View File
@@ -1,11 +1,10 @@
<?php namespace MapGuesser\Http; <?php namespace MapGuesser\Http;
class Request use MapGuesser\Interfaces\Http\IRequest;
use MapGuesser\Interfaces\Http\IResponse;
class Request implements IRequest
{ {
const HTTP_GET = 0;
const HTTP_POST = 1;
private string $url; private string $url;
private int $method; private int $method;
@@ -14,13 +13,23 @@ class Request
private array $headers = []; private array $headers = [];
public function __construct(string $url, int $method = self::HTTP_GET) public function __construct(string $url = '', int $method = self::HTTP_GET)
{ {
$this->url = $url; $this->url = $url;
$this->method = $method; $this->method = $method;
} }
public function setQuery($query) public function setUrl(string $url): void
{
$this->url = $url;
}
public function setMethod(int $method): void
{
$this->method = $method;
}
public function setQuery($query): void
{ {
if (is_string($query)) { if (is_string($query)) {
$this->query = $query; $this->query = $query;
@@ -29,22 +38,22 @@ class Request
} }
} }
public function setHeaders(array $headers) public function setHeaders(array $headers): void
{ {
$this->headers = array_merge($this->headers, $headers); $this->headers = array_merge($this->headers, $headers);
} }
public function send(): Response public function send(): IResponse
{ {
$ch = curl_init(); $ch = curl_init();
if ($this->method === self::HTTP_GET) { if ($this->method === self::HTTP_POST) {
$url = $this->url . '?' . $this->query; $url = $this->url;
} elseif ($this->method === self::HTTP_POST) {
$url = $this->url;
curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $this->query); curl_setopt($ch, CURLOPT_POSTFIELDS, $this->query);
} else {
$url = $this->url . '?' . $this->query;
} }
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
+5 -3
View File
@@ -1,6 +1,8 @@
<?php namespace MapGuesser\Http; <?php namespace MapGuesser\Http;
class Response use MapGuesser\Interfaces\Http\IResponse;
class Response implements IResponse
{ {
private string $body; private string $body;
@@ -12,12 +14,12 @@ class Response
$this->headers = $headers; $this->headers = $headers;
} }
public function getBody() public function getBody(): string
{ {
return $this->body; return $this->body;
} }
public function getHeaders() public function getHeaders(): array
{ {
return $this->headers; return $this->headers;
} }
+18
View File
@@ -0,0 +1,18 @@
<?php namespace MapGuesser\Interfaces\Http;
interface IRequest
{
const HTTP_GET = 0;
const HTTP_POST = 1;
public function setUrl(string $url): void;
public function setMethod(int $method): void;
public function setQuery($query): void;
public function setHeaders(array $headers): void;
public function send(): IResponse;
}
+8
View File
@@ -0,0 +1,8 @@
<?php namespace MapGuesser\Interfaces\Http;
interface IResponse
{
public function getBody(): string;
public function getHeaders(): array;
}
+1 -1
View File
@@ -4,7 +4,7 @@ use MapGuesser\Interfaces\Authentication\IUser;
interface IRequest interface IRequest
{ {
public function setParsedRouteParams(array &$routeParams); public function setParsedRouteParams(array &$routeParams): void;
public function getBase(): string; public function getBase(): string;
+4 -2
View File
@@ -2,9 +2,11 @@
interface IContent interface IContent
{ {
public function &getData(): array; public function setData(array $data): void;
public function &render(): string; public function getData(): array;
public function render(): void;
public function getContentType(): string; public function getContentType(): string;
} }
@@ -0,0 +1,9 @@
<?php namespace MapGuesser\Interfaces\Session;
use SessionHandlerInterface;
use SessionIdInterface;
use SessionUpdateTimestampHandlerInterface;
interface ISessionHandler extends SessionHandlerInterface, SessionIdInterface, SessionUpdateTimestampHandlerInterface
{
}
+1 -1
View File
@@ -77,7 +77,7 @@ class Mail
} }
} }
private function sendMail(PHPMailer $mailer, array $recipient) private function sendMail(PHPMailer $mailer, array $recipient): void
{ {
$mailer->clearAddresses(); $mailer->clearAddresses();
$mailer->addAddress($recipient[0], $recipient[1]); $mailer->addAddress($recipient[0], $recipient[1]);
+24 -9
View File
@@ -1,14 +1,21 @@
<?php namespace MapGuesser\OAuth; <?php namespace MapGuesser\OAuth;
use MapGuesser\Http\Request; use MapGuesser\Interfaces\Http\IRequest;
class GoogleOAuth class GoogleOAuth
{ {
private static $dialogUrlBase = 'https://accounts.google.com/o/oauth2/v2/auth'; private static string $dialogUrlBase = 'https://accounts.google.com/o/oauth2/v2/auth';
private static $tokenUrlBase = 'https://oauth2.googleapis.com/token'; private static string $tokenUrlBase = 'https://oauth2.googleapis.com/token';
public function getDialogUrl(string $state, string $redirectUrl): string private IRequest $request;
public function __construct(IRequest $request)
{
$this->request = $request;
}
public function getDialogUrl(string $state, string $redirectUrl, ?string $nonce = null, ?string $loginHint = null): string
{ {
$oauthParams = [ $oauthParams = [
'response_type' => 'code', 'response_type' => 'code',
@@ -16,13 +23,20 @@ class GoogleOAuth
'scope' => 'openid email', 'scope' => 'openid email',
'redirect_uri' => $redirectUrl, 'redirect_uri' => $redirectUrl,
'state' => $state, 'state' => $state,
'nonce' => hash('sha256', random_bytes(10) . microtime()),
]; ];
if ($nonce !== null) {
$oauthParams['nonce'] = $nonce;
}
if ($loginHint !== null) {
$oauthParams['login_hint'] = $loginHint;
}
return self::$dialogUrlBase . '?' . http_build_query($oauthParams); return self::$dialogUrlBase . '?' . http_build_query($oauthParams);
} }
public function getToken(string $code, string $redirectUrl) public function getToken(string $code, string $redirectUrl): array
{ {
$tokenParams = [ $tokenParams = [
'code' => $code, 'code' => $code,
@@ -32,9 +46,10 @@ class GoogleOAuth
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
]; ];
$request = new Request(self::$tokenUrlBase, Request::HTTP_POST); $this->request->setUrl(self::$tokenUrlBase);
$request->setQuery($tokenParams); $this->request->setMethod(IRequest::HTTP_POST);
$response = $request->send(); $this->request->setQuery($tokenParams);
$response = $this->request->send();
return json_decode($response->getBody(), true); return json_decode($response->getBody(), true);
} }
+46 -2
View File
@@ -5,12 +5,13 @@ use DateTime;
use MapGuesser\Http\Request; use MapGuesser\Http\Request;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
use MapGuesser\Util\Geo\Position; use MapGuesser\Util\Geo\Position;
use MapGuesser\Util\Panorama\Pov;
class Place extends Model class Place extends Model
{ {
protected static string $table = 'places'; protected static string $table = 'places';
protected static array $fields = ['map_id', 'lat', 'lng', 'pano_id_cached', 'pano_id_cached_timestamp']; protected static array $fields = ['map_id', 'lat', 'lng', 'pano_id_cached', 'pano_id_cached_timestamp', 'pov_heading', 'pov_pitch', 'pov_zoom'];
protected static array $relations = ['map' => Map::class]; protected static array $relations = ['map' => Map::class];
@@ -24,9 +25,12 @@ class Place extends Model
private ?DateTime $panoIdCachedTimestamp = null; private ?DateTime $panoIdCachedTimestamp = null;
private Pov $pov;
public function __construct() public function __construct()
{ {
$this->position = new Position(0.0, 0.0); $this->position = new Position(0.0, 0.0);
$this->pov = new Pov(0.0, 0.0, 0.0);
} }
public function setMap(Map $map): void public function setMap(Map $map): void
@@ -54,6 +58,26 @@ class Place extends Model
$this->position->setLng($lng); $this->position->setLng($lng);
} }
public function setPov(Pov $pov): void
{
$this->pov = $pov;
}
public function setPovHeading(float $heading): void
{
$this->pov->setHeading($heading);
}
public function setPovPitch(float $pitch): void
{
$this->pov->setPitch($pitch);
}
public function setPovZoom(float $zoom): void
{
$this->pov->setZoom($zoom);
}
public function setPanoIdCached(?string $panoIdCached): void public function setPanoIdCached(?string $panoIdCached): void
{ {
$this->panoIdCached = $panoIdCached; $this->panoIdCached = $panoIdCached;
@@ -96,6 +120,26 @@ class Place extends Model
return $this->position->getLng(); return $this->position->getLng();
} }
public function getPov(): Pov
{
return $this->pov;
}
public function getPovHeading(): float
{
return $this->pov->getHeading();
}
public function getPovPitch(): float
{
return $this->pov->getPitch();
}
public function getPovZoom(): float
{
return $this->pov->getZoom();
}
public function getFreshPanoId(bool $canBeIndoor = false): ?string public function getFreshPanoId(bool $canBeIndoor = false): ?string
{ {
if ( if (
@@ -122,7 +166,7 @@ class Place extends Model
} }
$this->panoIdCached = $panoId; $this->panoIdCached = $panoId;
$this->panoIdCachedTimestamp = new DateTime('now'); $this->panoIdCachedTimestamp = new DateTime();
(new PersistentDataManager())->saveToDb($this); (new PersistentDataManager())->saveToDb($this);
+32 -5
View File
@@ -1,12 +1,13 @@
<?php namespace MapGuesser\PersistentData\Model; <?php namespace MapGuesser\PersistentData\Model;
use DateTime;
use MapGuesser\Interfaces\Authentication\IUser; use MapGuesser\Interfaces\Authentication\IUser;
class User extends Model implements IUser class User extends Model implements IUser
{ {
protected static string $table = 'users'; protected static string $table = 'users';
protected static array $fields = ['email', 'password', 'type', 'active', 'google_sub']; protected static array $fields = ['email', 'password', 'type', 'active', 'google_sub', 'created'];
private static array $types = ['user', 'admin']; private static array $types = ['user', 'admin'];
@@ -20,6 +21,8 @@ class User extends Model implements IUser
private ?string $googleSub = null; private ?string $googleSub = null;
private DateTime $created;
public function setEmail(string $email): void public function setEmail(string $email): void
{ {
$this->email = $email; $this->email = $email;
@@ -42,9 +45,9 @@ class User extends Model implements IUser
} }
} }
public function setActive($active): void public function setActive(bool $active): void
{ {
$this->active = (bool) $active; $this->active = $active;
} }
public function setGoogleSub(?string $googleSub): void public function setGoogleSub(?string $googleSub): void
@@ -52,6 +55,16 @@ class User extends Model implements IUser
$this->googleSub = $googleSub; $this->googleSub = $googleSub;
} }
public function setCreatedDate(DateTime $created): void
{
$this->created = $created;
}
public function setCreated(string $created): void
{
$this->created = new DateTime($created);
}
public function getEmail(): string public function getEmail(): string
{ {
return $this->email; return $this->email;
@@ -77,15 +90,25 @@ class User extends Model implements IUser
return $this->googleSub; return $this->googleSub;
} }
public function getCreatedDate(): DateTime
{
return $this->created;
}
public function getCreated(): string
{
return $this->created->format('Y-m-d H:i:s');
}
public function hasPermission(int $permission): bool public function hasPermission(int $permission): bool
{ {
switch ($permission) { switch ($permission) {
case IUser::PERMISSION_NORMAL: case IUser::PERMISSION_NORMAL:
return true; return true;
break;
case IUser::PERMISSION_ADMIN: case IUser::PERMISSION_ADMIN:
return $this->type === 'admin'; return $this->type === 'admin';
break; default:
throw new \Exception('Permission does not exist: ' . $permission);
} }
} }
@@ -101,6 +124,10 @@ class User extends Model implements IUser
public function checkPassword(string $password): bool public function checkPassword(string $password): bool
{ {
if ($this->password === null) {
return false;
}
return password_verify($password, $this->password); return password_verify($password, $this->password);
} }
} }
+25 -1
View File
@@ -1,10 +1,12 @@
<?php namespace MapGuesser\PersistentData\Model; <?php namespace MapGuesser\PersistentData\Model;
use DateTime;
class UserConfirmation extends Model class UserConfirmation extends Model
{ {
protected static string $table = 'user_confirmations'; protected static string $table = 'user_confirmations';
protected static array $fields = ['user_id', 'token']; protected static array $fields = ['user_id', 'token', 'last_sent'];
protected static array $relations = ['user' => User::class]; protected static array $relations = ['user' => User::class];
@@ -14,6 +16,8 @@ class UserConfirmation extends Model
private string $token = ''; private string $token = '';
private DateTime $lastSent;
public function setUser(User $user): void public function setUser(User $user): void
{ {
$this->user = $user; $this->user = $user;
@@ -29,6 +33,16 @@ class UserConfirmation extends Model
$this->token = $token; $this->token = $token;
} }
public function setLastSentDate(DateTime $lastSent): void
{
$this->lastSent = $lastSent;
}
public function setLastSent(string $lastSent): void
{
$this->lastSent = new DateTime($lastSent);
}
public function getUser(): ?User public function getUser(): ?User
{ {
return $this->user; return $this->user;
@@ -43,4 +57,14 @@ class UserConfirmation extends Model
{ {
return $this->token; return $this->token;
} }
public function getLastSentDate(): DateTime
{
return $this->lastSent;
}
public function getLastSent(): string
{
return $this->lastSent->format('Y-m-d H:i:s');
}
} }
@@ -0,0 +1,70 @@
<?php namespace MapGuesser\PersistentData\Model;
use DateTime;
class UserPasswordResetter extends Model
{
protected static string $table = 'user_password_resetters';
protected static array $fields = ['user_id', 'token', 'expires'];
protected static array $relations = ['user' => User::class];
private ?User $user = null;
private ?int $userId = null;
private string $token = '';
private DateTime $expires;
public function setUser(User $user): void
{
$this->user = $user;
}
public function setUserId(int $userId): void
{
$this->userId = $userId;
}
public function setToken(string $token): void
{
$this->token = $token;
}
public function setExpiresDate(DateTime $expires): void
{
$this->expires = $expires;
}
public function setExpires(string $expires): void
{
$this->expires = new DateTime($expires);
}
public function getUser(): ?User
{
return $this->user;
}
public function getUserId(): ?int
{
return $this->userId;
}
public function getToken(): string
{
return $this->token;
}
public function getExpiresDate(): DateTime
{
return $this->expires;
}
public function getExpires(): string
{
return $this->expires->format('Y-m-d H:i:s');
}
}
+45 -25
View File
@@ -1,5 +1,6 @@
<?php namespace MapGuesser\PersistentData; <?php namespace MapGuesser\PersistentData;
use Generator;
use MapGuesser\Database\Query\Modify; use MapGuesser\Database\Query\Modify;
use MapGuesser\Database\Query\Select; use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet; use MapGuesser\Interfaces\Database\IResultSet;
@@ -7,32 +8,10 @@ use MapGuesser\PersistentData\Model\Model;
class PersistentDataManager class PersistentDataManager
{ {
public function selectFromDb(Select $select, string $type, bool $withRelations = false): ?Model public function selectFromDb(Select $select, string $type, bool $withRelations = false)
{ {
$table = call_user_func([$type, 'getTable']); $select = $this->createSelect($select, $type, $withRelations);
$fields = call_user_func([$type, 'getFields']);
$select->from($table);
//TODO: only with some relations?
if ($withRelations) {
$relations = call_user_func([$type, 'getRelations']);
$columns = [];
foreach ($fields as $field) {
$columns[] = [$table, $field];
}
$columns = array_merge($columns, $this->getRelationColumns($relations));
$this->leftJoinRelations($select, $table, $relations);
$select->columns($columns);
} else {
$select->columns($fields);
}
//TODO: return with array?
$data = $select->execute()->fetch(IResultSet::FETCH_ASSOC); $data = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
if ($data === null) { if ($data === null) {
@@ -45,7 +24,20 @@ class PersistentDataManager
return $model; return $model;
} }
public function selectFromDbById($id, string $type, bool $withRelations = false): ?Model public function selectMultipleFromDb(Select $select, string $type, bool $withRelations = false): Generator
{
$select = $this->createSelect($select, $type, $withRelations);
$result = $select->execute();
while ($data = $result->fetch(IResultSet::FETCH_ASSOC)) {
$model = new $type();
$this->fillWithData($data, $model);
yield $model;
}
}
public function selectFromDbById($id, string $type, bool $withRelations = false)
{ {
$select = new Select(\Container::$dbConnection); $select = new Select(\Container::$dbConnection);
$select->whereId($id); $select->whereId($id);
@@ -136,6 +128,34 @@ class PersistentDataManager
$model->resetSnapshot(); $model->resetSnapshot();
} }
private function createSelect(Select $select, string $type, bool $withRelations = false): Select
{
$table = call_user_func([$type, 'getTable']);
$fields = call_user_func([$type, 'getFields']);
$select->from($table);
//TODO: only with some relations?
if ($withRelations) {
$relations = call_user_func([$type, 'getRelations']);
$columns = [];
foreach ($fields as $field) {
$columns[] = [$table, $field];
}
$columns = array_merge($columns, $this->getRelationColumns($relations));
$this->leftJoinRelations($select, $table, $relations);
$select->columns($columns);
} else {
$select->columns($fields);
}
return $select;
}
private function getRelationColumns(array $relations): array private function getRelationColumns(array $relations): array
{ {
$columns = []; $columns = [];
+20 -2
View File
@@ -1,6 +1,8 @@
<?php namespace MapGuesser\Repository; <?php namespace MapGuesser\Repository;
use Generator;
use MapGuesser\Database\Query\Select; use MapGuesser\Database\Query\Select;
use MapGuesser\PersistentData\Model\Map;
use MapGuesser\PersistentData\Model\Place; use MapGuesser\PersistentData\Model\Place;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
@@ -18,6 +20,15 @@ class PlaceRepository
return $this->pdm->selectFromDbById($placeId, Place::class); return $this->pdm->selectFromDbById($placeId, Place::class);
} }
public function getAllForMap(Map $map): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('map_id', '=', $map->getId());
yield from $this->pdm->selectMultipleFromDb($select, Place::class);
}
//TODO: use Map instead of id
public function getRandomForMapWithValidPano(int $mapId, array $exclude = [], array &$placesWithoutPano): Place public function getRandomForMapWithValidPano(int $mapId, array $exclude = [], array &$placesWithoutPano): Place
{ {
$placesWithoutPano = []; $placesWithoutPano = [];
@@ -35,13 +46,20 @@ class PlaceRepository
return $place; return $place;
} }
private function selectRandomFromDbForMap(int $mapId, array $exclude): ?Place private function selectRandomFromDbForMap(int $mapId, array $exclude): Place
{ {
//TODO: omit table name here
$select = new Select(\Container::$dbConnection, 'places'); $select = new Select(\Container::$dbConnection, 'places');
$select->where('id', 'NOT IN', $exclude); $select->where('id', 'NOT IN', $exclude);
$select->where('map_id', '=', $mapId); $select->where('map_id', '=', $mapId);
$numberOfPlaces = $select->count(); // TODO: what if 0 $numberOfPlaces = $select->count();
//TODO: prevent this
if ($numberOfPlaces === 0) {
throw new \Exception('There is no selectable place (count was 0).');
}
$randomOffset = random_int(0, $numberOfPlaces - 1); $randomOffset = random_int(0, $numberOfPlaces - 1);
$select->orderBy('id'); $select->orderBy('id');
@@ -1,6 +1,7 @@
<?php namespace MapGuesser\Repository; <?php namespace MapGuesser\Repository;
use MapGuesser\Database\Query\Select; use MapGuesser\Database\Query\Select;
use MapGuesser\PersistentData\Model\User;
use MapGuesser\PersistentData\Model\UserConfirmation; use MapGuesser\PersistentData\Model\UserConfirmation;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
@@ -25,4 +26,12 @@ class UserConfirmationRepository
return $this->pdm->selectFromDb($select, UserConfirmation::class); return $this->pdm->selectFromDb($select, UserConfirmation::class);
} }
public function getByUser(User $user): ?UserConfirmation
{
$select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $user->getId());
return $this->pdm->selectFromDb($select, UserConfirmation::class);
}
} }
@@ -0,0 +1,47 @@
<?php namespace MapGuesser\Repository;
use DateTime;
use Generator;
use MapGuesser\Database\Query\Select;
use MapGuesser\PersistentData\Model\User;
use MapGuesser\PersistentData\Model\UserPasswordResetter;
use MapGuesser\PersistentData\PersistentDataManager;
class UserPasswordResetterRepository
{
private PersistentDataManager $pdm;
public function __construct()
{
$this->pdm = new PersistentDataManager();
}
public function getById(int $userConfirmationId): ?UserPasswordResetter
{
return $this->pdm->selectFromDbById($userConfirmationId, UserPasswordResetter::class);
}
public function getByToken(string $token): ?UserPasswordResetter
{
$select = new Select(\Container::$dbConnection);
$select->where('token', '=', $token);
return $this->pdm->selectFromDb($select, UserPasswordResetter::class);
}
public function getByUser(User $user): ?UserPasswordResetter
{
$select = new Select(\Container::$dbConnection);
$select->where('user_id', '=', $user->getId());
return $this->pdm->selectFromDb($select, UserPasswordResetter::class);
}
public function getAllExpired(): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('expires', '<', (new DateTime())->format('Y-m-d H:i:s'));
yield from $this->pdm->selectMultipleFromDb($select, UserPasswordResetter::class);
}
}
+11
View File
@@ -1,5 +1,7 @@
<?php namespace MapGuesser\Repository; <?php namespace MapGuesser\Repository;
use DateTime;
use Generator;
use MapGuesser\Database\Query\Select; use MapGuesser\Database\Query\Select;
use MapGuesser\PersistentData\Model\User; use MapGuesser\PersistentData\Model\User;
use MapGuesser\PersistentData\PersistentDataManager; use MapGuesser\PersistentData\PersistentDataManager;
@@ -33,4 +35,13 @@ class UserRepository
return $this->pdm->selectFromDb($select, User::class); return $this->pdm->selectFromDb($select, User::class);
} }
public function getAllInactiveExpired(): Generator
{
$select = new Select(\Container::$dbConnection);
$select->where('active', '=', false);
$select->where('created', '<', (new DateTime('-1 day'))->format('Y-m-d H:i:s'));
yield from $this->pdm->selectMultipleFromDb($select, User::class);
}
} }
+1 -1
View File
@@ -39,7 +39,7 @@ class Request implements IRequest
} }
} }
public function setParsedRouteParams(array &$routeParams) public function setParsedRouteParams(array &$routeParams): void
{ {
$this->routeParams = &$routeParams; $this->routeParams = &$routeParams;
} }
+4 -4
View File
@@ -11,12 +11,12 @@ class Session implements ISession
$this->data = &$data; $this->data = &$data;
} }
public function has($key): bool public function has(string $key): bool
{ {
return isset($this->data[$key]); return isset($this->data[$key]);
} }
public function get($key) public function get(string $key)
{ {
if (isset($this->data[$key])) { if (isset($this->data[$key])) {
return $this->data[$key]; return $this->data[$key];
@@ -25,12 +25,12 @@ class Session implements ISession
return null; return null;
} }
public function set($key, $value): void public function set(string $key, $value): void
{ {
$this->data[$key] = $value; $this->data[$key] = $value;
} }
public function delete($key): void public function delete(string $key): void
{ {
unset($this->data[$key]); unset($this->data[$key]);
} }
+7 -2
View File
@@ -6,12 +6,17 @@ abstract class ContentBase implements IContent
{ {
protected array $data; protected array $data;
public function &getData(): array public function setData(array $data): void
{
$this->data = $data;
}
public function getData(): array
{ {
return $this->data; return $this->data;
} }
abstract public function &render(): string; abstract public function render(): void;
abstract public function getContentType(): string; abstract public function getContentType(): string;
} }
+15 -10
View File
@@ -1,25 +1,30 @@
<?php namespace MapGuesser\Response; <?php namespace MapGuesser\Response;
use MapGuesser\View\Linker;
class HtmlContent extends ContentBase class HtmlContent extends ContentBase
{ {
private string $template; private string $view;
public function __construct(string $template, array &$data = []) public function __construct(string $view, array $data = [])
{ {
$this->template = $template; $this->view = $view;
$this->data = &$data; $this->data = $data;
} }
public function &render(): string public function render(): void
{ {
if (!empty($_ENV['DEV'])) {
$generator = new Linker($this->view);
$generator->generate();
}
extract($this->data); extract($this->data);
ob_start(); require ROOT . '/cache/views/' . $this->view . '.php';
require ROOT . '/views/' . $this->template . '.php';
$content = ob_get_contents();
ob_end_clean();
return $content; // @phpstan-ignore-next-line - SCRIPT_STARTED is defined in main.php
echo '<!-- __debug__runtime: ' . round((hrtime(true) - SCRIPT_STARTED) / 1e+6, 1) . ' -->';
} }
public function getContentType(): string public function getContentType(): string
+6 -5
View File
@@ -2,16 +2,17 @@
class JsonContent extends ContentBase class JsonContent extends ContentBase
{ {
public function __construct(array &$data = []) public function __construct(array $data = [])
{ {
$this->data = &$data; $this->data = $data;
} }
public function &render(): string public function render(): void
{ {
$content = json_encode($this->data); // @phpstan-ignore-next-line - SCRIPT_STARTED is defined in main.php
$this->data['__debug__runtime'] = round((hrtime(true) - SCRIPT_STARTED) / 1e+6, 1);
return $content; echo json_encode($this->data);
} }
public function getContentType(): string public function getContentType(): string
+1 -1
View File
@@ -16,7 +16,7 @@ class Redirect implements IRedirect
public function getUrl(): string public function getUrl(): string
{ {
if (preg_match('/^http(s)?/', $this->target)) { if (preg_match('/^http(s)?/', $this->target) === 1) {
$link = $this->target; $link = $this->target;
} else { } else {
$link = \Container::$request->getBase() . '/' . $this->target; $link = \Container::$request->getBase() . '/' . $this->target;
+2 -2
View File
@@ -30,7 +30,7 @@ class Route
$link = []; $link = [];
foreach ($this->pattern as $fragment) { foreach ($this->pattern as $fragment) {
if (preg_match('/^{(\\w+)(\\?)?}$/', $fragment, $matches)) { if (preg_match('/^{(\\w+)(\\?)?}$/', $fragment, $matches) === 1) {
if (isset($parameters[$matches[1]])) { if (isset($parameters[$matches[1]])) {
$link[] = $parameters[$matches[1]]; $link[] = $parameters[$matches[1]];
unset($parameters[$matches[1]]); unset($parameters[$matches[1]]);
@@ -61,7 +61,7 @@ class Route
$parameters = []; $parameters = [];
foreach ($path as $i => $fragment) { foreach ($path as $i => $fragment) {
if (preg_match('/^{(\\w+)(?:\\?)?}$/', $this->pattern[$i], $matches)) { if (preg_match('/^{(\\w+)(?:\\?)?}$/', $this->pattern[$i], $matches) === 1) {
$parameters[$matches[1]] = $fragment; $parameters[$matches[1]] = $fragment;
} elseif ($fragment != $this->pattern[$i]) { } elseif ($fragment != $this->pattern[$i]) {
return null; return null;
+1 -1
View File
@@ -76,7 +76,7 @@ class RouteCollection
$this->searchTable[$method][$groupNumber][] = $route; $this->searchTable[$method][$groupNumber][] = $route;
while (preg_match('/^{\\w+\\?}$/', end($pattern))) { while (preg_match('/^{\\w+\\?}$/', end($pattern)) === 1) {
$groupNumber--; $groupNumber--;
array_pop($pattern); array_pop($pattern);
+15 -24
View File
@@ -4,11 +4,9 @@ use DateTime;
use MapGuesser\Database\Query\Modify; use MapGuesser\Database\Query\Modify;
use MapGuesser\Database\Query\Select; use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet; use MapGuesser\Interfaces\Database\IResultSet;
use SessionHandlerInterface; use MapGuesser\Interfaces\Session\ISessionHandler;
use SessionIdInterface;
use SessionUpdateTimestampHandlerInterface;
class DatabaseSessionHandler implements SessionHandlerInterface, SessionIdInterface, SessionUpdateTimestampHandlerInterface class DatabaseSessionHandler implements ISessionHandler
{ {
private bool $exists = false; private bool $exists = false;
@@ -28,7 +26,7 @@ class DatabaseSessionHandler implements SessionHandlerInterface, SessionIdInterf
{ {
$select = new Select(\Container::$dbConnection, 'sessions'); $select = new Select(\Container::$dbConnection, 'sessions');
$select->columns(['data']); $select->columns(['data']);
$select->whereId($id); $select->whereId(substr($id, 0, 32));
$result = $select->execute()->fetch(IResultSet::FETCH_ASSOC); $result = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
@@ -46,16 +44,16 @@ class DatabaseSessionHandler implements SessionHandlerInterface, SessionIdInterf
$modify = new Modify(\Container::$dbConnection, 'sessions'); $modify = new Modify(\Container::$dbConnection, 'sessions');
if ($this->exists) { if ($this->exists) {
$modify->setId($id); $modify->setId(substr($id, 0, 32));
} else { } else {
$modify->setExternalId($id); $modify->setExternalId(substr($id, 0, 32));
} }
$modify->set('data', $data); $modify->set('data', $data);
$modify->set('updated', (new DateTime())->format('Y-m-d H:i:s')); $modify->set('updated', (new DateTime())->format('Y-m-d H:i:s'));
$modify->save(); $modify->save();
$written = true; $this->written = true;
return true; return true;
} }
@@ -63,37 +61,30 @@ class DatabaseSessionHandler implements SessionHandlerInterface, SessionIdInterf
public function destroy($id): bool public function destroy($id): bool
{ {
$modify = new Modify(\Container::$dbConnection, 'sessions'); $modify = new Modify(\Container::$dbConnection, 'sessions');
$modify->setId($id); $modify->setId(substr($id, 0, 32));
$modify->delete(); $modify->delete();
$this->exists = false;
return true; return true;
} }
public function gc($maxlifetime): int public function gc($maxlifetime): bool
{ {
$select = new Select(\Container::$dbConnection, 'sessions'); // empty on purpose
$select->columns(['id']); // old sessions are deleted by MaintainDatabaseCommand
$select->where('updated', '<', (new DateTime('-' . $maxlifetime . ' seconds'))->format('Y-m-d H:i:s'));
$result = $select->execute();
while ($session = $result->fetch(IResultSet::FETCH_ASSOC)) {
$modify = new Modify(\Container::$dbConnection, 'sessions');
$modify->setId($session['id']);
$modify->delete();
}
return true; return true;
} }
public function create_sid(): string public function create_sid(): string
{ {
return hash('sha256', random_bytes(10) . microtime()); return bin2hex(random_bytes(16));
} }
public function validateId($id): bool public function validateId($id): bool
{ {
return preg_match('/^[a-f0-9]{64}$/', $id); return preg_match('/^[a-f0-9]{32}$/', $id) === 1;
} }
public function updateTimestamp($id, $data): bool public function updateTimestamp($id, $data): bool
@@ -104,7 +95,7 @@ class DatabaseSessionHandler implements SessionHandlerInterface, SessionIdInterf
$modify = new Modify(\Container::$dbConnection, 'sessions'); $modify = new Modify(\Container::$dbConnection, 'sessions');
$modify->setId($id); $modify->setId(substr($id, 0, 32));
$modify->set('updated', (new DateTime())->format('Y-m-d H:i:s')); $modify->set('updated', (new DateTime())->format('Y-m-d H:i:s'));
$modify->save(); $modify->save();
+1 -1
View File
@@ -27,7 +27,7 @@ class Bounds
public static function createDirectly(float $southLat, float $westLng, float $northLat, float $eastLng): Bounds public static function createDirectly(float $southLat, float $westLng, float $northLat, float $eastLng): Bounds
{ {
$instance = new static(); $instance = new self();
$instance->southLat = $southLat; $instance->southLat = $southLat;
$instance->westLng = $westLng; $instance->westLng = $westLng;
+1 -1
View File
@@ -11,7 +11,7 @@ class JwtParser
} }
} }
public function setToken(string $token) public function setToken(string $token): void
{ {
$this->token = explode('.', str_replace(['_', '-'], ['/', '+'], $token)); $this->token = explode('.', str_replace(['_', '-'], ['/', '+'], $token));
} }
+56
View File
@@ -0,0 +1,56 @@
<?php namespace MapGuesser\Util\Panorama;
class Pov
{
private float $heading;
private float $pitch;
private float $zoom;
public function __construct(float $heading, float $pitch, float $zoom)
{
$this->heading = $heading;
$this->pitch = $pitch;
$this->zoom = $zoom;
}
public function setHeading(float $heading): void
{
$this->heading = $heading;
}
public function setPitch(float $pitch): void
{
$this->pitch = $pitch;
}
public function setZoom(float $zoom): void
{
$this->zoom = $zoom;
}
public function getHeading(): float
{
return $this->heading;
}
public function getPitch(): float
{
return $this->pitch;
}
public function getZoom(): float
{
return $this->zoom;
}
public function toArray(): array
{
return [
'heading' => $this->heading,
'pitch' => $this->pitch,
'zoom' => $this->zoom
];
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php namespace MapGuesser\View;
class Linker
{
const INLINE_ASSET_LIMIT = 2000;
private string $view;
public function __construct(string $view)
{
$this->view = $view;
}
public function generate(): void
{
$input = ROOT . '/views/' . $this->view . '.php';
$temporaryFiles = [];
$sections = ['externalCss' => '', 'inlineCss' => '', 'externalJs' => '', 'inlineJs' => ''];
$extra = ['', ''];
do {
$parser = new Parser($input);
$fragment = $parser->parse();
$extends = $fragment->getExtends();
$this->generateAssets($fragment, $sections);
$sections = array_merge($sections, $fragment->getSections()); //TODO: detect if section defined multiple times
$extra[0] = $fragment->getExtra()[0] . $extra[0];
$extra[1] = $extra[1] . $fragment->getExtra()[1];
if ($extends === null) {
$this->writeFinal($extra, $input, ROOT . '/cache/views/' . $this->view . '.php');
break;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'mapg-view-');
$temporaryFiles[] = $tmpFile;
$this->extendTemplate($sections, ROOT . '/views/' . $extends . '.php', $tmpFile);
$input = $tmpFile;
} while (true);
foreach ($temporaryFiles as $tmpFile) {
unlink($tmpFile);
}
}
private function extendTemplate(array $sections, string $file, string $output): void
{
$inputFileHandle = fopen($file, 'r');
if (!$inputFileHandle) {
throw new \Exception('Cannot open file ' . $file);
}
$outputFileHandle = fopen($output, 'w');
if (!$outputFileHandle) {
throw new \Exception('Cannot open file ' . $output . 'for writing.');
}
$lineNumber = 0;
while (($line = fgets($inputFileHandle)) !== false) {
++$lineNumber;
if (preg_match('/^\s*@yields\(\'([\w\/]+)\'\)\s*$/', $line, $matches) === 1) {
if (isset($sections[$matches[1]])) {
fwrite($outputFileHandle, $sections[$matches[1]]);
}
} else {
fwrite($outputFileHandle, $line);
}
}
fclose($inputFileHandle);
fclose($outputFileHandle);
}
private function writeFinal(array $extra, string $file, string $output): void
{
$dirname = pathinfo($output, PATHINFO_DIRNAME);
if (!is_dir($dirname)) {
mkdir($dirname, 0755, true);
}
$inputFileHandle = fopen($file, 'r');
if (!$inputFileHandle) {
throw new \Exception('Cannot open file ' . $file);
}
$outputFileHandle = fopen($output, 'w');
if (!$outputFileHandle) {
throw new \Exception('Cannot open file ' . $output . 'for writing.');
}
fwrite($outputFileHandle, $extra[0]);
while (($line = fgets($inputFileHandle)) !== false) {
fwrite($outputFileHandle, $line);
}
fwrite($outputFileHandle, $extra[1]);
fclose($inputFileHandle);
fclose($outputFileHandle);
}
private function generateAssets(ParsedFragment $fragment, array &$sections): void
{
foreach ($fragment->getCss() as $cssFile) {
$asset = $this->parseAsset($cssFile);
if (isset($asset['code'])) {
$sections['inlineCss'] .= '<style>' . PHP_EOL;
$sections['inlineCss'] .= $asset['code'];
$sections['inlineCss'] .= '</style>' . PHP_EOL;
} elseif (isset($asset['file'])) {
$sections['externalCss'] .= '<link href="' . $asset['file'] . '" rel="stylesheet">' . PHP_EOL;
}
}
foreach ($fragment->getJs() as $jsFile) {
$asset = $this->parseAsset($jsFile);
if (isset($asset['code'])) {
$sections['inlineJs'] .= '<script>' . PHP_EOL;
$sections['inlineJs'] .= $asset['code'];
$sections['inlineJs'] .= '</script>' . PHP_EOL;
} elseif (isset($asset['file'])) {
$sections['externalJs'] .= '<script src="' . $asset['file'] . '"></script>' . PHP_EOL;
}
}
}
private function parseAsset(string $asset): array
{
$output = [];
if (preg_match('/^[\w\/\.]+$/', $asset) === 1) {
if (
empty($_ENV['DEV']) &&
filesize(ROOT . '/public/static/' . $asset) < self::INLINE_ASSET_LIMIT
) {
$output['code'] = file_get_contents(ROOT . '/public/static/' . $asset);
} else {
$output['file'] = '<?= $_ENV[\'STATIC_ROOT\'] ?>/' . $asset . '?rev=<?= REVISION ?>';
}
} else {
$output['file'] = $asset;
}
return $output;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php namespace MapGuesser\View;
class ParsedFragment
{
private ?string $extends;
private array $css;
private array $js;
private array $sections;
private array $extra;
public function __construct(?string $extends, array $css, array $js, array $sections, array $extra)
{
$this->extends = $extends;
$this->css = $css;
$this->js = $js;
$this->sections = $sections;
$this->extra = $extra;
}
public function getExtends(): ?string
{
return $this->extends;
}
public function getCss(): array
{
return $this->css;
}
public function getJs(): array
{
return $this->js;
}
public function getSections(): array
{
return $this->sections;
}
public function getExtra(): array
{
return $this->extra;
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php namespace MapGuesser\View;
class Parser
{
private string $file;
public function __construct(string $file)
{
$this->file = $file;
}
public function parse(): ParsedFragment
{
$sectionOpen = null;
$extraOpen = false;
$extends = null;
$js = [];
$css = [];
$sections = [];
$extra = ['', ''];
$fileHandle = fopen($this->file, 'r');
if (!$fileHandle) {
throw new \Exception('Cannot open file ' . $this->file);
}
$lineNumber = 0;
while (($line = fgets($fileHandle)) !== false) {
++$lineNumber;
if (($cssMatched = $this->matchCss($line)) !== null) {
$css[] = $cssMatched;
continue;
}
if (($jsMatched = $this->matchJs($line)) !== null) {
$js[] = $jsMatched;
continue;
}
if (($extendsMatched = $this->matchExtends($line)) !== null) {
if ($extends !== null) {
throw new \Exception('Error in file ' . $this->file . ' in line ' . $lineNumber . ' - There is already an \'@extends\' declared.');
}
$extends = $extendsMatched;
continue;
}
if (($sectionMatched = $this->matchSection($line)) !== null) {
if ($extends === null) {
throw new \Exception('Error in file ' . $this->file . ' in line ' . $lineNumber . ' - \'@section\' has no meaning if view extends nothing.');
}
if ($sectionOpen !== null) {
throw new \Exception('Parse error in file ' . $this->file . ' in line ' . $lineNumber . ' - A \'@section\' is already open (no \'@endsection\' found).');
}
$sectionOpen = $sectionMatched;
$sections[$sectionOpen] = '';
continue;
}
if ($this->matchEndSection($line)) {
if ($sectionOpen === null) {
throw new \Exception('Parse error in file ' . $this->file . ' in line ' . $lineNumber . ' - Cannot end section until no \'@section\' is open.');
}
$sectionOpen = null;
}
if ($this->matchExtra($line)) {
if ($extraOpen) {
throw new \Exception('Parse error in file ' . $this->file . ' in line ' . $lineNumber . ' - An \'@extra\' is already open (no \'@endextra\' found).');
}
$extraOpen = true;
continue;
}
if ($this->matchEndExtra($line)) {
if (!$extraOpen) {
throw new \Exception('Parse error in file ' . $this->file . ' in line ' . $lineNumber . ' - Cannot end extra until no \'@extra\' is open.');
}
$extraOpen = false;
}
if ($sectionOpen !== null) {
$sections[$sectionOpen] .= $line;
}
if ($extraOpen) {
$offset = $extends === null ? 0 : 1;
$extra[$offset] .= $line;
}
}
fclose($fileHandle);
return new ParsedFragment($extends, $css, $js, $sections, $extra);
}
private function matchCss(string $line): ?string
{
if (preg_match('/^\s*@css\((.*)\)\s*$/', $line, $matches) === 1) {
return $matches[1];
}
return null;
}
private function matchJs(string $line): ?string
{
if (preg_match('/^\s*@js\((.*)\)\s*$/', $line, $matches) === 1) {
return $matches[1];
}
return null;
}
private function matchExtends(string $line): ?string
{
if (preg_match('/^\s*@extends\(([\w\/]+)\)\s*$/', $line, $matches) === 1) {
return $matches[1];
}
return null;
}
private function matchSection(string $line): ?string
{
if (preg_match('/^\s*@section\((\w+)\)\s*$/', $line, $matches) === 1) {
return $matches[1];
}
return null;
}
private function matchEndSection(string $line): bool
{
return preg_match('/^\s*@endsection(?:\(\))?\s*$/', $line) === 1;
}
private function matchExtra(string $line): bool
{
return preg_match('/^\s*@extra(?:\(\))?\s*$/', $line) === 1;
}
private function matchEndExtra(string $line): bool
{
return preg_match('/^\s*@endextra(?:\(\))?\s*$/', $line) === 1;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace MapGuesser\Tests\OAuth;
use MapGuesser\Interfaces\Http\IRequest;
use MapGuesser\Interfaces\Http\IResponse;
use MapGuesser\OAuth\GoogleOAuth;
use PHPUnit\Framework\TestCase;
final class GoogleOAuthTest extends TestCase
{
public function testCanCreateDialogUrl(): void
{
$_ENV['GOOGLE_OAUTH_CLIENT_ID'] = 'xyz';
$state = 'random_state_string';
$nonce = 'random_nonce_string';
$redirectUrl = 'http://example.com/oauth';
$requestMock = $this->getMockBuilder(IRequest::class)
->setMethods(['setUrl', 'setMethod', 'setQuery', 'setHeaders', 'send'])
->getMock();
$googleOAuth = new GoogleOAuth($requestMock);
$dialogUrl = $googleOAuth->getDialogUrl($state, $redirectUrl, $nonce);
$dialogUrlParsed = explode('?', $dialogUrl);
$this->assertEquals('https://accounts.google.com/o/oauth2/v2/auth', $dialogUrlParsed[0]);
parse_str($dialogUrlParsed[1], $dialogUrlQueryParams);
$expectedQueryParams = [
'response_type' => 'code',
'client_id' => $_ENV['GOOGLE_OAUTH_CLIENT_ID'],
'scope' => 'openid email',
'redirect_uri' => $redirectUrl,
'state' => $state,
'nonce' => $nonce,
];
$this->assertEquals($expectedQueryParams, $dialogUrlQueryParams);
}
public function testCanRequestToken(): void
{
$_ENV['GOOGLE_OAUTH_CLIENT_ID'] = 'abc';
$_ENV['GOOGLE_OAUTH_CLIENT_SECRET'] = 'xxx';
$code = 'code_from_google';
$redirectUrl = 'http://example.com/oauth';
$requestMock = $this->getMockBuilder(IRequest::class)
->setMethods(['setUrl', 'setMethod', 'setQuery', 'setHeaders', 'send'])
->getMock();
$responseMock = $this->getMockBuilder(IResponse::class)
->setMethods(['getBody', 'getHeaders'])
->getMock();
$googleOAuth = new GoogleOAuth($requestMock);
$expectedQueryParams = [
'code' => $code,
'client_id' => $_ENV['GOOGLE_OAUTH_CLIENT_ID'],
'client_secret' => $_ENV['GOOGLE_OAUTH_CLIENT_SECRET'],
'redirect_uri' => $redirectUrl,
'grant_type' => 'authorization_code',
];
$requestMock->expects($this->once())
->method('setUrl')
->with($this->equalTo('https://oauth2.googleapis.com/token'));
$requestMock->expects($this->once())
->method('setMethod')
->with($this->equalTo(IRequest::HTTP_POST));
$requestMock->expects($this->once())
->method('setQuery')
->with($this->equalTo($expectedQueryParams));
$requestMock->expects($this->once())
->method('send')
->will($this->returnValue($responseMock));
$responseMock->expects($this->once())
->method('getBody')
->will($this->returnValue('{"test":"json"}'));
$token = $googleOAuth->getToken($code, $redirectUrl);
$this->assertEquals(['test' => 'json'], $token);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php namespace MapGuesser\Tests\Util;
use MapGuesser\PersistentData\Model\Model;
use PHPUnit\Framework\TestCase;
class OtherModel
{
}
class DummyModel extends Model
{
protected static string $table = 'test_table';
protected static array $fields = ['name', 'valid'];
protected static array $relations = ['other_model' => OtherModel::class];
private string $name;
private bool $valid;
public function setName(string $name): void
{
$this->name = $name;
}
public function setValid(bool $valid): void
{
$this->valid = $valid;
}
public function getName(): string
{
return $this->name;
}
public function getValid(): bool
{
return $this->valid;
}
}
final class ModelTest extends TestCase
{
public function testCanReturnTable(): void
{
$this->assertEquals('test_table', DummyModel::getTable());
}
public function testCanReturnFields(): void
{
$this->assertEquals(['id', 'name', 'valid'], DummyModel::getFields());
}
public function testCanReturnRelations(): void
{
$this->assertEquals(['other_model' => OtherModel::class], DummyModel::getRelations());
}
public function testCanBeConvertedToArray(): void
{
$model = new DummyModel();
$model->setId(123);
$model->setName('John');
$model->setValid(true);
$this->assertEquals([
'id' => 123,
'name' => 'John',
'valid' => true
], $model->toArray());
}
public function testCanSaveAndResetSnapshot(): void
{
$model = new DummyModel();
$model->setId(123);
$model->setName('John');
$model->setValid(true);
$model->saveSnapshot();
$this->assertEquals([
'id' => 123,
'name' => 'John',
'valid' => true
], $model->getSnapshot());
$model->resetSnapshot();
$this->assertEquals([], $model->getSnapshot());
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php namespace MapGuesser\Tests\Util\Geo;
use MapGuesser\Util\Geo\Bounds;
use MapGuesser\Util\Geo\Position;
use PHPUnit\Framework\TestCase;
final class BoundsTest extends TestCase
{
public function testCanBeCreatedEmpty(): void
{
$bounds = new Bounds();
$this->assertEquals(90.0, $bounds->getSouthLat());
$this->assertEquals(180.0, $bounds->getWestLng());
$this->assertEquals(-90.0, $bounds->getNorthLat());
$this->assertEquals(-180.0, $bounds->getEastLng());
}
public function testCanBeCreatedWithPosition(): void
{
$position = new Position(45.25, 18.12);
$bounds = new Bounds($position);
$this->assertEquals($position->getLat(), $bounds->getSouthLat());
$this->assertEquals($position->getLng(), $bounds->getWestLng());
$this->assertEquals($position->getLat(), $bounds->getNorthLat());
$this->assertEquals($position->getLng(), $bounds->getEastLng());
}
public function testCanBeCreatedDirectly(): void
{
$bounds = Bounds::createDirectly(44.12, 7.33, 50.12, 15.11);
$this->assertEquals(44.12, $bounds->getSouthLat());
$this->assertEquals(7.33, $bounds->getWestLng());
$this->assertEquals(50.12, $bounds->getNorthLat());
$this->assertEquals(15.11, $bounds->getEastLng());
}
public function testExtendingWithOnePositionIsTheSameAsCreatingWithPosition(): void
{
$position = new Position(45.25, 18.12);
$bounds1 = new Bounds();
$bounds1->extend($position);
$bounds2 = new Bounds($position);
$this->assertEquals($bounds1, $bounds2);
}
public function testCanBeExtendedWithTwoPositions(): void
{
$bounds = new Bounds();
$position1 = new Position(45.25, 18.12);
$position2 = new Position(43.15, 19.28);
$bounds->extend($position1);
$bounds->extend($position2);
$this->assertEquals($position2->getLat(), $bounds->getSouthLat());
$this->assertEquals($position1->getLng(), $bounds->getWestLng());
$this->assertEquals($position1->getLat(), $bounds->getNorthLat());
$this->assertEquals($position2->getLng(), $bounds->getEastLng());
$bounds = new Bounds();
$position1 = new Position(43.15, 18.12);
$position2 = new Position(45.25, 19.28);
$bounds->extend($position1);
$bounds->extend($position2);
$this->assertEquals($position1->getLat(), $bounds->getSouthLat());
$this->assertEquals($position1->getLng(), $bounds->getWestLng());
$this->assertEquals($position2->getLat(), $bounds->getNorthLat());
$this->assertEquals($position2->getLng(), $bounds->getEastLng());
}
public function testCanCalculateApproximateArea(): void
{
$bounds = Bounds::createDirectly(44.12, 7.33, 50.12, 15.11);
$this->assertEqualsWithDelta(391766.09, $bounds->calculateApproximateArea(), 0.01);
}
public function testCanBeConvertedToArray(): void
{
$bounds = Bounds::createDirectly(44.12, 7.33, 50.12, 15.11);
$this->assertEquals(
['south' => 44.12, 'west' => 7.33, 'north' => 50.12, 'east' => 15.11],
$bounds->toArray()
);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php namespace MapGuesser\Tests\Util\Geo;
use MapGuesser\Util\Geo\Position;
use PHPUnit\Framework\TestCase;
final class PositionTest extends TestCase
{
public function testCanBeCreatedWithLatAndLng(): void
{
$position = new Position(45.25, 18.12);
$this->assertEquals(45.25, $position->getLat());
$this->assertEquals(18.12, $position->getLng());
}
public function testCanCalculateDistanceToAnotherPosition(): void
{
$position1 = new Position(44.12, 7.33);
$position2 = new Position(50.12, 15.11);
$this->assertEqualsWithDelta(888785.73, $position1->calculateDistanceTo($position2), 0.01);
}
public function testCanBeConvertedToArray(): void
{
$position = new Position(45.25, 18.12);
$this->assertEquals(
['lat' => 45.25, 'lng' => 18.12],
$position->toArray()
);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php namespace MapGuesser\Tests\Util;
use MapGuesser\Util\JwtParser;
use PHPUnit\Framework\TestCase;
final class JwtParserTest extends TestCase
{
private JwtParser $jwtParser;
protected function setUp(): void
{
$this->jwtParser = new JwtParser(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
);
}
public function testSettingTokenIsTheSameAsCreatingWithToken(): void
{
$jwtParser2 = new JwtParser();
$jwtParser2->setToken(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
);
$this->assertEquals($this->jwtParser, $jwtParser2);
}
public function testCanParseTokenHeader(): void
{
$this->assertEquals([
'alg' => 'HS256',
'typ' => 'JWT'
], $this->jwtParser->getHeader());
}
public function testCanParseTokenPayload(): void
{
$this->assertEquals([
'sub' => '1234567890',
'name' => 'John Doe',
'iat' => 1516239022
], $this->jwtParser->getPayload());
}
public function testCanParseTokenSignature(): void
{
$this->assertEquals(
'49f94ac7044948c78a285d904f87f0a4c7897f7e8f3a4eb2255fda750b2cc397',
bin2hex($this->jwtParser->getSignature())
);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php namespace MapGuesser\Tests\View\Parser;
use MapGuesser\View\ParsedFragment;
use MapGuesser\View\Parser;
use PHPUnit\Framework\TestCase;
final class ParserTest extends TestCase
{
const TEST_VIEWS_PATH = __DIR__ . '/../../views/tests';
public function testCanParseViewWithoutExtends(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_without_extends.php');
$parser = new Parser($file);
$fragment = $parser->parse();
$expected = new ParsedFragment(
null,
[],
[],
[],
['', '']
);
$this->assertEquals($expected, $fragment);
}
public function testCanParseViewWithAssets(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_with_assets.php');
$parser = new Parser($file);
$fragment = $parser->parse();
$expected = new ParsedFragment(
null,
[
'test.css'
],
[
'test.js',
'test_<?= $some[\'expression\'] ?>'
],
[],
['', '']
);
$this->assertEquals($expected, $fragment);
}
public function testCanParseViewWithExtends(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_with_extends.php');
$parser = new Parser($file);
$fragment = $parser->parse();
$expected = new ParsedFragment(
'parent',
[],
[],
[
'section1' => '<div>Test HTML with @extends</div>' . "\n"
],
['', '']
);
$this->assertEquals($expected, $fragment);
}
public function testCanParseComplexView(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_complex.php');
$parser = new Parser($file);
$fragment = $parser->parse();
$expected = new ParsedFragment(
'parent',
[
'test1.css'
],
[
'test1.js',
'test2_<?= $some[\'expression\'] ?>'
],
[
'section1' => '<div>Test HTML with @extends - section 1</div>' . "\n",
'section2' => '<div>Test HTML with @extends - section 2</div>' . "\n"
],
[
'<?php phpinfo() ?>' . "\n",
'<?php $a = \'test_string\' ?>' . "\n" . 'EXTRA' . "\n",
]
);
$this->assertEquals($expected, $fragment);
}
public function testFailsIfMultipleExtendsGiven(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_invalid_multiple_extends.php');
$parser = new Parser($file);
$this->expectExceptionMessage('Error in file ' . $file . ' in line 3 - There is already an \'@extends\' declared.');
$parser->parse();
}
public function testFailsIfSectionWithoutExtendsGiven(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_invalid_section_without_extends.php');
$parser = new Parser($file);
$this->expectExceptionMessage('Error in file ' . $file . ' in line 1 - \'@section\' has no meaning if view extends nothing.');
$parser->parse();
}
public function testFailsIfOpeningSectionBeforePreviousClosed(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_invalid_multiple_sections_open.php');
$parser = new Parser($file);
$this->expectExceptionMessage('Parse error in file ' . $file . ' in line 4 - A \'@section\' is already open (no \'@endsection\' found).');
$parser->parse();
}
public function testFailsIfClosingSectionWhenNoSectionIsOpen(): void
{
$file = realpath(self::TEST_VIEWS_PATH . '/view_invalid_section_not_open.php');
$parser = new Parser($file);
$this->expectExceptionMessage('Parse error in file ' . $file . ' in line 4 - Cannot end section until no \'@section\' is open.');
$parser->parse();
}
}
+40
View File
@@ -0,0 +1,40 @@
@js(js/account/account.js)
@extends(templates/layout_normal)
@section(main)
<h2>Account</h2>
<div class="box">
<form id="accountForm" action="/account" method="post" data-observe-inputs="password_new,password_new_confirm">
<?php if ($user['password'] !== null && $user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with your password or with Google to modify your account.</p>
<div class="inputWithButton">
<input type="password" name="password" placeholder="Current password" required minlength="6" autofocus><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button>
</div>
<?php elseif ($user['password'] !== null): ?>
<p class="justify small">Please confirm your identity with your password to modify your account.</p>
<input class="big fullWidth" type="password" name="password" placeholder="Current password" required minlength="6" autofocus>
<?php elseif ($user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with Google to modify your account.</p>
<div class="inputWithButton">
<input type="text" name="password" placeholder="Authenticate with Google..." disabled><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button>
</div>
<?php endif; ?>
<hr>
<?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" minlength="6">
<input class="big fullWidth marginTop" type="password" name="password_new_confirm" placeholder="New password confirmation" minlength="6">
<p id="accountFormError" class="formError justify marginTop"></p>
<div class="right marginTop">
<button type="submit" name="submit" disabled>Save</button>
</div>
<hr>
<div class="center">
<a class="button red" href="/account/delete" title="Delete account">Delete account</a>
</div>
</form>
</div>
@endsection
+33
View File
@@ -0,0 +1,33 @@
@js(js/account/account.js)
@extends(templates/layout_normal)
@section(main)
<h2>Delete account</h2>
<div class="box">
<form id="deleteAccountForm" action="/account/delete" method="post" data-redirect-on-success="/">
<p class="justify marginBottom">Are you sure you want to delete your account? This cannot be undone!</p>
<?php if ($user['password'] !== null && $user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with your password or with Google to delete your account.</p>
<div class="inputWithButton">
<input type="password" name="password" placeholder="Current password" required minlength="6" autofocus><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button>
</div>
<?php elseif ($user['password'] !== null): ?>
<p class="justify small">Please confirm your identity with your password to delete your account.</p>
<input class="big fullWidth" type="password" name="password" placeholder="Current password" required minlength="6" autofocus>
<?php elseif ($user['google_sub'] !== null): ?>
<p class="justify small">Please confirm your identity with Google to delete your account.</p>
<div class="inputWithButton">
<input type="text" name="password" placeholder="Authenticate with Google..." disabled><!--
--><button id="authenticateWithGoogleButton" class="yellow" type="button">Google</button>
</div>
<?php endif; ?>
<p id="deleteAccountFormError" class="formError justify marginTop"></p>
<div class="right marginTop">
<button class="red marginRight" type="submit" name="submit">Delete account</button><!--
--><a class="button gray marginTop" href="/account" title="Back to account">Cancel</a>
</div>
</form>
</div>
@endsection
+28
View File
@@ -0,0 +1,28 @@
@js(js/account/google_authenticate.js)
@extends(templates/layout_minimal)
@section(main)
<h2>Authenticate with Google</h2>
<?php if (!$success): ?>
<div class="box">
<p class="error justify">
<?php if (isset($errorText)): ?>
<?= $errorText ?>
<?php else: ?>
Authentication with Google failed.
<?php endif; ?>
Please <a id="closeWindowButton" href="javascript:;" title="Close">close this window/tab</a> and try again!
</p>
</div>
<?php endif; ?>
@endsection
@section(pageScript)
<script>
var success = <?= $success ? 'true' : 'false' ?>;
<?php if (isset($authenticatedWithGoogleUntil)): ?>
var authenticatedWithGoogleUntil = new Date('<?= $authenticatedWithGoogleUntil->format('c') ?>');
<?php endif; ?>
</script>
@endsection
+38 -41
View File
@@ -1,27 +1,17 @@
<?php @css(node_modules/leaflet/dist/leaflet.css)
$cssFiles = [ @css(css/map_editor.css)
'node_modules/leaflet/dist/leaflet.css', @css(node_modules/leaflet.markercluster/dist/MarkerCluster.css)
'css/map_editor.css', @css(node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css)
'node_modules/leaflet.markercluster/dist/MarkerCluster.css',
'node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css' @js(node_modules/leaflet/dist/leaflet.js)
]; @js(node_modules/leaflet.markercluster/dist/leaflet.markercluster.js)
$jsFiles = [ @js(https://maps.googleapis.com/maps/api/js?key=<?= $_ENV['GOOGLE_MAPS_JS_API_KEY'] ?>)
'node_modules/leaflet/dist/leaflet.js', @js(js/map_editor.js)
'node_modules/leaflet.markercluster/dist/leaflet.markercluster.js',
'https://maps.googleapis.com/maps/api/js?key=' . $_ENV['GOOGLE_MAPS_JS_API_KEY'], @extends(templates/layout_full)
'js/map_editor.js',
]; @section(subheader)
?> <span><a href="javascript:;" id="mapName" title="Edit map data"><?= $mapName ?></a></span><!--
<?php require ROOT . '/views/templates/main_header.php'; ?>
<header class="small">
<h1>
<a href="/" title="<?= $_ENV['APP_NAME'] ?>">
<img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>"><!--
--><span><?= $_ENV['APP_NAME'] ?></span>
</a>
</h1>
<p class="header">
<span><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">
@@ -46,11 +36,12 @@ $jsFiles = [
</svg><!-- </svg><!--
--><span id="deleted" class="bold">0</span><!-- --><span id="deleted" class="bold">0</span><!--
--></span> --></span>
</p> @endsection
</header>
@section(pagemodal)
<div id="metadata" class="modal"> <div id="metadata" class="modal">
<h2>Edit map data</h2> <h2>Edit map data</h2>
<form id="metadataForm" class="marginTop"> <form id="metadataForm" class="marginTop" data-no-submit="true">
<input class="fullWidth" type="text" name="name" value="<?= $mapName ?>" placeholder="Name of the map"> <input class="fullWidth" type="text" name="name" value="<?= $mapName ?>" placeholder="Name of the map">
<textarea class="fullWidth marginTop" name="description" rows="4" placeholder="Description of the map"><?= $mapDescription ?></textarea> <textarea class="fullWidth marginTop" name="description" rows="4" placeholder="Description of the map"><?= $mapDescription ?></textarea>
<div class="right"> <div class="right">
@@ -59,19 +50,25 @@ $jsFiles = [
</div> </div>
</form> </form>
</div> </div>
<div id="map"></div> @endsection
<div id="control">
<button id="saveButton" class="fullWidth" disabled>Save</button> @section(main)
</div> <div id="map"></div>
<div id="panorama"></div> <div id="panorama"></div>
<div id="noPano"> <div id="noPano">
<p class="bold">No panorama is available for this location.</p> <p class="bold">No panorama is available for this location.</p>
</div> </div>
<div id="placeControl"> <div id="control">
<button id="applyButton" class="fullWidth">Apply</button> <button id="saveButton" class="fullWidth" disabled>Save</button>
<button id="closeButton" class="gray fullWidth marginTop">Close</button> </div>
<button id="deleteButton" class="red fullWidth marginTop">Delete</button> <div id="placeControl">
</div> <button id="applyButton" class="fullWidth">Apply</button>
<button id="closeButton" class="gray fullWidth marginTop">Close</button>
<button id="deleteButton" class="red fullWidth marginTop">Delete</button>
</div>
@endsection
@section(pageScript)
<script> <script>
var tileUrl = '<?= $_ENV['LEAFLET_TILESERVER_URL'] ?>'; var tileUrl = '<?= $_ENV['LEAFLET_TILESERVER_URL'] ?>';
var tileAttribution = '<?= $_ENV['LEAFLET_TILESERVER_ATTRIBUTION'] ?>'; var tileAttribution = '<?= $_ENV['LEAFLET_TILESERVER_ATTRIBUTION'] ?>';
@@ -79,4 +76,4 @@ $jsFiles = [
var mapBounds = <?= json_encode($bounds) ?>; var mapBounds = <?= json_encode($bounds) ?>;
var places = <?= json_encode($places, JSON_FORCE_OBJECT) ?>; var places = <?= json_encode($places, JSON_FORCE_OBJECT) ?>;
</script> </script>
<?php require ROOT . '/views/templates/main_footer.php'; ?> @endsection
+4 -4
View File
@@ -1,6 +1,6 @@
<?php require ROOT . '/views/templates/main_header.php'; ?> @extends(templates/layout_normal)
<?php require ROOT . '/views/templates/header.php'; ?>
@section(main)
<h2>404 | Page not found</h2> <h2>404 | Page not found</h2>
<p>The requested URL was not found on this server. <a href="/" title="<?= $_ENV['APP_NAME'] ?>">Back to start.</a></p> <p>The requested URL was not found on this server. <a href="/" title="<?= $_ENV['APP_NAME'] ?>">Back to start.</a></p>
<?php require ROOT . '/views/templates/footer.php'; ?> @endsection
<?php require ROOT . '/views/templates/main_footer.php'; ?>
+43 -49
View File
@@ -1,62 +1,56 @@
<?php @css(css/game.css)
$cssFiles = [
'css/game.css' @js(https://maps.googleapis.com/maps/api/js?key=<?= $_ENV['GOOGLE_MAPS_JS_API_KEY'] ?>)
]; @js(js/game.js)
$jsFiles = [
'https://maps.googleapis.com/maps/api/js?key=' . $_ENV['GOOGLE_MAPS_JS_API_KEY'], @extends(templates/layout_full)
'js/game.js',
]; @section(subheader)
?>
<?php require ROOT . '/views/templates/main_header.php'; ?>
<header class="small">
<h1>
<a href="/" title="<?= $_ENV['APP_NAME'] ?>">
<img class="inline" width="1em" height="1em" src="<?= $_ENV['STATIC_ROOT'] ?>/img/icon.svg?rev=<?= REVISION ?>"><!--
--><span><?= $_ENV['APP_NAME'] ?></span>
</a>
</h1>
<p class="header">
<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> @endsection
</header>
<div id="guessCover"></div> @section(main)
<div id="panorama"></div> <div id="panoCover"></div>
<div id="showGuessButtonContainer"> <div id="panorama"></div>
<button id="showGuessButton" class="fullWidth">Show guess map</button> <div id="showGuessButtonContainer">
</div> <button id="showGuessButton" class="fullWidth">Show guess map</button>
<div id="guess">
<div id="closeGuessButtonContainer" class="buttonContainer marginBottom">
<button id="closeGuessButton" class="fullWidth gray">Close</button>
</div> </div>
<div id="map"></div> <div id="guess">
<div id="guessButtonContainer" class="buttonContainer marginTop"> <div id="closeGuessButtonContainer" class="buttonContainer marginBottom">
<button id="guessButton" class="fullWidth" disabled>Guess</button> <button id="closeGuessButton" class="fullWidth gray">Close</button>
</div>
<div id="resultInfo">
<div id="distanceInfo">
<p>You were <span id="distance" class="bold"></span> close.</p>
<p class="bold">Game finished.</p>
</div> </div>
<div id="scoreInfo"> <div id="map"></div>
<p>You earned <span id="score" class="bold"></span> points.</p> <div id="guessButtonContainer" class="buttonContainer marginTop">
<p>You got <span id="scoreSum" class="bold"></span> points in total.</p> <button id="guessButton" class="fullWidth" disabled>Guess</button>
</div> </div>
<div> <div id="resultInfo">
<div id="scoreBarBase"> <div id="distanceInfo">
<div id="scoreBar"></div> <p>You were <span id="distance" class="bold"></span> close.</p>
<p class="bold">Game finished.</p>
</div>
<div id="scoreInfo">
<p>You earned <span id="score" class="bold"></span> points.</p>
<p>You got <span id="scoreSum" class="bold"></span> points in total.</p>
</div>
<div>
<div id="scoreBarBase">
<div id="scoreBar"></div>
</div>
</div> </div>
</div> </div>
<div id="continueButtonContainer" class="buttonContainer marginTop">
<button id="continueButton" class="fullWidth">Continue</button>
<button id="showSummaryButton" class="fullWidth">Show summary</button>
<button id="startNewGameButton" class="fullWidth">Play this map again</button>
</div>
</div> </div>
<div id="continueButtonContainer" class="buttonContainer marginTop"> @endsection
<button id="continueButton" class="fullWidth">Continue</button>
<button id="showSummaryButton" class="fullWidth">Show summary</button> @section(pageScript)
<button id="startNewGameButton" class="fullWidth">Play this map again</button>
</div>
</div>
<script> <script>
var mapId = <?= $mapId ?>; var mapId = <?= $mapId ?>;
var mapBounds = <?= json_encode($bounds) ?>; var mapBounds = <?= json_encode($bounds) ?>;
</script> </script>
<?php require ROOT . '/views/templates/main_footer.php'; ?> @endsection
+5 -5
View File
@@ -1,8 +1,8 @@
<?php require ROOT . '/views/templates/main_header.php'; ?> @extends(templates/layout_normal)
<?php require ROOT . '/views/templates/header.php'; ?>
@section(main)
<h2>Account activation</h2> <h2>Account activation</h2>
<div class="box"> <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> <p class="error justify">Activation failed. Please check the link you entered, or retry <a href="/signup" title="Sign up">signing up</a>!</p>
</div> </div>
<?php require ROOT . '/views/templates/footer.php'; ?> @endsection
<?php require ROOT . '/views/templates/main_footer.php'; ?>
+4 -4
View File
@@ -1,5 +1,6 @@
<?php require ROOT . '/views/templates/main_header.php'; ?> @extends(templates/layout_normal)
<?php require ROOT . '/views/templates/header.php'; ?>
@section(main)
<h2>Account cancellation</h2> <h2>Account cancellation</h2>
<div class="box"> <div class="box">
<?php if ($success) : ?> <?php if ($success) : ?>
@@ -8,5 +9,4 @@
<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> <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; ?> <?php endif; ?>
</div> </div>
<?php require ROOT . '/views/templates/footer.php'; ?> @endsection
<?php require ROOT . '/views/templates/main_footer.php'; ?>
+5 -5
View File
@@ -1,8 +1,8 @@
<?php require ROOT . '/views/templates/main_header.php'; ?> @extends(templates/layout_normal)
<?php require ROOT . '/views/templates/header.php'; ?>
@section(main)
<h2>Login up with Google</h2> <h2>Login up with Google</h2>
<div class="box"> <div class="box">
<p class="error justify">Authenticating with Google failed. Please <a href="/login/google" title="Login with Google">retry</a>!</p> <p class="error justify">Authentication with Google failed. Please <a href="/login/google" title="Login with Google">try again</a>!</p>
</div> </div>
<?php require ROOT . '/views/templates/footer.php'; ?> @endsection
<?php require ROOT . '/views/templates/main_footer.php'; ?>
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more