commit ddcacca5c389595dd28ee8718c8176e7ff3f4bcb Author: soneill Date: Sun Aug 30 19:59:19 2020 +1200 private fork from github diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..84ec63c --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +path="" +CONTAINER_NAME="pf" +DOMAIN="" +SERVER_NAME="" +APP_PASSWORD="" +MYSQL_PASSWORD="" +#API KEYS +CCP_SSO_CLIENT_ID="" +CCP_SSO_SECRET_KEY="" +CCP_ESI_SCOPES="esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1,esi-universe.read_structures.v1,esi-corporations.read_corporation_membership.v1,esi-clones.read_clones.v1" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d1f2605 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "pathfinder"] + path = pathfinder + url = https://github.com/samoneilll/pathfinder +[submodule "websocket"] + path = websocket + url = https://github.com/exodus4d/pathfinder_websocket diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9438400 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM php:7.2.5-fpm-alpine3.7 as build +#FROM php:7.4.4-fpm-alpine3.11 as build +RUN apk update \ + && apk add --no-cache libpng-dev zeromq-dev git \ + $PHPIZE_DEPS \ + && docker-php-ext-install gd && docker-php-ext-install pdo_mysql && pecl install redis && docker-php-ext-enable redis && pecl install channel://pecl.php.net/zmq-1.1.3 && docker-php-ext-enable zmq \ + && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +COPY pathfinder /app +WORKDIR /app +RUN composer install + +#FROM trafex/alpine-nginx-php7:latest +FROM trafex/alpine-nginx-php7:ba1dd422 +#USER root +RUN apk update && apk add --no-cache busybox-suid sudo php7-redis php7-pdo php7-pdo_mysql php7-fileinfo shadow gettext bash apache2-utils +#RUN usermod -u 1000 nobody +#RUN groupmod -g 1000 nobody + +COPY static/nginx/nginx.conf /etc/nginx/templateNginx.conf +COPY static/nginx/site.conf /etc/nginx/sites_enabled/templateSite.conf + +# Configure PHP-FPM +COPY static/php/fpm-pool.conf /etc/php7/php-fpm.d/zzz_custom.conf +#COPY static/php/php.ini /etc/php7/conf.d/zzz_custom.ini + +COPY static/php/php.ini /etc/zzz_custom.ini +# configure cron +COPY static/crontab.txt /var/crontab.txt +# Configure supervisord +COPY static/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY static/entrypoint.sh / +#RUN apk add sudo php7-redis php7-pdo php7-pdo_mysql +WORKDIR /var/www/html +COPY --chown=nobody --from=build /app pathfinder +##RUN chown -R nobody:nobody /var/www/html/pathfinder + +RUN chmod 0766 pathfinder/logs pathfinder/tmp/ && rm index.php && touch /etc/nginx/.setup_pass && chmod +x /entrypoint.sh +COPY static/pathfinder/routes.ini /var/www/html/pathfinder/app/ +COPY static/pathfinder/environment.ini /var/www/html/pathfinder/app/templateEnvironment.ini +#USER nobody +WORKDIR /var/www/html +EXPOSE 80 +#ENTRYPOINT ["sh", "-c", "source /.env && /entrypoint.sh", "-s"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..41be398 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +THE FOLLOWING SETS FORTH ATTRIBUTION NOTICES FOR THIRD PARTY SOFTWARE THAT MAY BE CONTAINED IN PORTIONS OF THE PATHFINDER CONTAINER PRODUCT. + +MIT License + +Copyright (c) 2020-present techfreak and other contributors + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..28d6ff1 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Pathfinder Container + +**Pathfinder Container** is a docker-compose setup that contains a hassle free out of the box setup for [Pathfinder](https://developers.eveonline.com/https://github.com/exodus4d/pathfinder). + +**Features** +* Setup Script for easy setup +* Password Protection of the setup page +* Socket Server running out of the box +* Automatic Restart in-case of crash +* Easy update with git tags +### How to run it + +**Prerequisites**: +* [docker](https://docs.docker.com/) +* [docker-compose](https://docs.docker.com/) + +1. **Create an [API-Key](https://developers.eveonline.com/) with the scopes listed in the [wiki](https://github.com/exodus4d/pathfinder/wiki/SSO-ESI)** + +2. **Clone the repo** +```shell +git clone --recurse-submodules https://gitlab.com/techfreak/pathfinder-container +``` + +## Setup Script +3. **Run the setup script** +```shell +chmod +x setup.sh +./setup.sh +``` + +4. **Profit ! Connect it to nginx or let traefik discover it** +## Running it manually +3. **Edit the .env file and make sure every config option has an entry.** +```shell +#the folder path of this folder e.g /home/tech/Development/DOCKER/pathfinder-container +path="" +CONTAINER_NAME="pf" +DOMAIN="" +SERVER_NAME="" +APP_PASSWORD="" +MYSQL_PASSWORD="" +CCP_SSO_CLIENT_ID="" +CCP_SSO_SECRET_KEY="" +CCP_ESI_SCOPES="esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1,esi-universe.read_structures.v1,esi-corporations.read_corporation_membership.v1,esi-clones.read_clones.v1" +``` + +4. **Build & Run it** +```shell +docker-compose build && docker-compose up -d +``` + +5. **Open the http://< your-domain >/setup page. Your username is pf and password is the password you set in APP_PASSWORD. Click on create database for eve_universe and pathfinder. And click on setup tables && fix column/keys.** + + +6. **Go back to your console and insert the eve universe dump with this command ** +```shell +docker-compose exec pfdb /bin/sh -c "unzip -p eve_universe.sql.zip | mysql -u root -p\$MYSQL_ROOT_PASSWORD eve_universe"; +``` + +7. **Profit ! Connect it to nginx or let traefik discover it** + +### Acknowledgments +* [exodus4d](https://github.com/exodus4d/) for pathfinder +* [Markus Geiger](https://gist.github.com/blurayne/f63c5a8521c0eeab8e9afd8baa45c65e) for his awesome bash menu + +### Authors +* techfreak + +### License +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details + diff --git a/config/pathfinder/config.ini b/config/pathfinder/config.ini new file mode 100644 index 0000000..bfe63bc --- /dev/null +++ b/config/pathfinder/config.ini @@ -0,0 +1,144 @@ +; Global Framework Config + +[SERVER] +SERVER_NAME = CARLFINDER + +[globals] +; Verbosity level of error stack trace for errors +; This affects error logging and stack traces returned to clients on error. +; DEBUG level can be overwritten in environment.ini +; Syntax: 0 | 1 | 2 | 3 +; Default: 0 +DEBUG = 0 + +; How to behave on 'non-fatal' errors +; If TRUE, the framework, after having logged stack trace and errors, stops execution +; (die without any status) when a non-fatal error is detected. +; Hint: You should not change this. +; Syntax: TRUE | FALSE +; Default: FALSE +HALT = FALSE + +; Timezone to use +; Sync Pathfinder with EVE server time. +; Hint: You should not change this. +; Default: UTC +TZ = UTC + +; Default language +; Overwrites HTTP Accept-Language request header. +; Used by setlocale() and affects number formatting. +; Syntax: String +; Default: en-US +LANGUAGE = en-US + +; Cache key prefix +; Same for all cache values for this installation. +; CLI (cronjob) scripts use it for cache manipulation. +; Hint: You should not change this. +; Syntax String +; Default: {{ md5(@SERVER.SERVER_NAME) }} +SEED = {{ md5(@SERVER.SERVER_NAME) }} + +; Cache backend +; This sets the primary cache backend for Pathfinder. Used for e.g.: +; DB query, DB schema, HTTP response, or even simple key->value caches +; Can handle Redis, Memcache module, APC, WinCache, XCache and a filesystem-based cache. +; Hint: Redis is recommended and gives the best performance. +; Syntax: folder=[DIR] | redis=[SERVER] +; Default: folder=tmp/cache/ +; Value: FALSE +; - Disables caching +; folder=[DIR] +; - Cache data is stored on disc +; redis=[SERVER] +; - Cache data is stored in Redis. redis=[host]:[port]:[db]:[auth] (e.g. redis=localhost:6379:1:myPass) +CACHE = redis=${CONTAINER_NAME}-redis:6379 + +; Cache backend for API data +; This sets the cache backend for API response data and other temp data relates to API requests. +; Response data with proper 'Expire' HTTP Header will be cached here and speed up further requests. +; As default 'API_CACHE' and 'CACHE' share the same backend (cache location) +; Hint1: You can specify e.g. a dedicated Redis DB here, then 'CACHE' and 'API_CACHE' can be cleared independently +; Hint2: Redis is recommended and gives the best performance. +; Default: {{@CACHE}} +; Value: FALSE +; - Disables caching +; folder=[DIR] +; - Cache data is stored on disc +; redis=[SERVER] +; - Cache data is stored in Redis. redis=[host]:[port]:[db]:[auth] (e.g. redis=localhost:6379:2:myPass) +API_CACHE = {{@CACHE}} + +; Cache backend used by PHPs Session handler. +; Hint1: Best performance and recommended configuration for Pathfinder is to configured Redis as PHPs default Session handler +; in your php.ini and set 'default' value here in order to use Redis (fastest) +; Hint2: If Redis is not available for you, leave this at 'mysql' (faster than PHPs default files bases Sessions) +; Syntax: mysql | default +; Default: mysql +; Value: mysql +; - Session data get stored in 'pathfinder'.'sessions' table (environment.ini → DB_PF_NAME). +; Table `sessions` is auto created if not exist. +; default +; - Session data get stored in PHPs default Session handler (php.ini → session.save_handler and session.save_path) +; PHPs default session.save_handler is `files` and each Session is written to disc (slowest) +SESSION_CACHE = mysql + +; Callback functions ============================================================================== +ONERROR = {{ @NAMESPACE }}\Controller\Controller->showError +UNLOAD = {{ @NAMESPACE }}\Controller\Controller->unload + +; Path configurations ============================================================================= +; All path configurations are relative to BASE dir and should NOT be changed + +; Temporary folder for cache +; Used for compiled templates. +; Syntax: [DIR] +; Default: tmp/ +TEMP = tmp/ + +; Log file folder +; Syntax: [DIR] +; Default: logs/ +LOGS = logs/ + +; UI folder +; Where all the public assets (templates, images, styles, scripts) are located. +; Syntax: [DIR] +; Default: public/ +UI = public/ + +; Autoload folder +; Where PHP attempts to autoload PHP classes at runtime. +; Syntax: [DIR] +; Default: app/ +;AUTOLOAD = app/ + +; Favicon folder +; Syntax: [DIR] +; Default: favicon/ +FAVICON = favicon/ + +; Export folder +; Where DB dump files are located/created at. +; Syntax: [DIR] +; Default: export/ +EXPORT = export/ + +; Custom *.ini file folder +; Can be used to overwrite default *.ini files and settings +; See: https://github.com/exodus4d/pathfinder/wiki/Configuration#custom-confpathfinderini +; Syntax: [DIR] +CONF.CUSTOM = conf/ +CONF.DEFAULT = app/ + +; Load additional config files +; DO NOT load environment.ini, it is loaded automatically +[configs] +{{@CONF.DEFAULT}}routes.ini = true +{{@CONF.DEFAULT}}pathfinder.ini = true +{{@CONF.DEFAULT}}plugin.ini = true +{{@CONF.CUSTOM}}pathfinder.ini = true +{{@CONF.CUSTOM}}plugin.ini = true +{{@CONF.DEFAULT}}requirements.ini = true +{{@CONF.DEFAULT}}cron.ini = true diff --git a/config/pathfinder/pathfinder.ini b/config/pathfinder/pathfinder.ini new file mode 100644 index 0000000..d198b06 --- /dev/null +++ b/config/pathfinder/pathfinder.ini @@ -0,0 +1,397 @@ +; Pathfinder Config + +[PATHFINDER] +; Name of installation +; This can be changed to any name +; This name is used in e.g. emails, user interface +; Syntax: String +; Default: Pathfinder +NAME = Goryn Are Lost + +; Pathfinder version +; Version number should not be changed manually. +; Version is used for CSS/JS cache busting and is part of the URL for static resources: +; e.g. public/js/vX.X.X/app.js +; Syntax: String (current version) +; Default: v2.0.0 +VERSION = v2.0.1 + +; Contact information [optional] +; Shown on 'licence', 'contact' page. +; Syntax: String +; Default: https://github.com/exodus4d +CONTACT = https://github.com/exodus4d + +; Public contact email [optional] +; Syntax: String +; Default: +EMAIL = + +; Repository URL [optional] +; Used for 'licence', 'contact' page. +; Syntax: String +; Default: https://github.com/exodus4d/pathfinder +REPO = https://github.com/exodus4d/pathfinder + +; Show warning on 'login' page if /setup route is active +; DO NOT disable this warning unless /setup route is protected or commented in routes.ini +; Syntax: 0 | 1 +; Default: 1 +SHOW_SETUP_WARNING = 0 + +; Show full login page +; If disabled, some section don´t appear: +; 'Slideshow', 'Features', 'Admin', 'Install', 'About' +; Syntax: 0 | 1 +; Default: 1 +SHOW_COMPLETE_LOGIN_PAGE = 1 + +; REGISTRATION ==================================================================================== +[PATHFINDER.REGISTRATION] +; Registration status (for new users) +; If disabled, users can no longer register a new account on this installation. +; Syntax: 0 | 1 +; Default: 1 +STATUS = 1 + +[PATHFINDER.LOGIN] +; Expire time for login cookies +; Login Cookie information send by clients is re-validated by the server. +; The expire time for each cookie is stored in DB. Expired Cookies become invalid. +; Syntax: Integer (days) +; Default: 30 +COOKIE_EXPIRE = 30 + +; Show 'scheduled maintenance' warning +; If enabled, active users will see a notification panel. +; This can be used to inform users about upcoming maintenance shutdown. +; This flag can be enabled "on the fly" (no page reload required to see the notice). +; Syntax: 0 | 1 +; Default: 0 +MODE_MAINTENANCE = 0 + +; Login restrictions (white lists) +; Login/registration can be restricted to specific groups. +; Use comma separated strings for CCP Ids (e.g. 1000166,1000080). +; If no groups are specified, all characters are allowed. +; Syntax: String (comma separated) +; Default: +CHARACTER = +CORPORATION = +ALLIANCE = + +[PATHFINDER.CHARACTER] +; Auto location select for characters +; If enabled, characters can activate the "auto location select" checkbox in their account settings. +; If checkbox active, solar systems get auto selected on map based on their current system. +; Hint: This can increase server load because of more client requests. +; Syntax: 0 | 1 +; Default: 1 +AUTO_LOCATION_SELECT = 1 + +; Slack API integration =========================================================================== +[PATHFINDER.SLACK] +; Slack API status +; This is a global toggle for all Slack related features. +; Check PATHFINDER.MAP section for individual control. +; Syntax: 0 | 1 +; Default: 1 +STATUS = 1 + +; Discord API integration ========================================================================= +[PATHFINDER.DISCORD] +; Discord API status +; This is a global toggle for all Discord related features. +; Check PATHFINDER.MAP section for individual control. +; Syntax: 0 | 1 +; Default: 1 +STATUS = 1 + +; View ============================================================================================ +[PATHFINDER.VIEW] +; Page templates +; Hint: You should not change this. +INDEX = templates/view/index.html +SETUP = templates/view/setup.html +LOGIN = templates/view/login.html +ADMIN = templates/view/admin.html + +; HTTP status pages =============================================================================== +[PATHFINDER.STATUS] +; Error page templates +; Hint: You should not change this. +4XX = templates/status/4xx.html +5XX = templates/status/5xx.html + +; MAP ============================================================================================= +; Map settings for 'private', 'corporation' and 'alliance' maps: +; LIFETIME (days) +; - Map will be deleted after 'X' days, by cronjob +; MAX_COUNT +; - Users can create/view up to 'X' maps of a type +; MAX_SHARED +; - Max number of shared entities per map +; MAX_SYSTEMS +; - Max number of active systems per map +; LOG_ACTIVITY_ENABLED (Syntax: 0 | 1) +; - Whether user activity statistics can be enabled for a map type +; - E.g. create/update/delete of systems/connections/signatures/... +; LOG_HISTORY_ENABLED (Syntax: 0 | 1) +; - Whether map change history should be logged to separate *.log files +; - see: [PATHFINDER.HISTORY] config section below +; SEND_HISTORY_SLACK_ENABLED (Syntax: 0 | 1) +; - Send map updates to a Slack channel per map +; SEND_RALLY_SLACK_ENABLED (Syntax: 0 | 1) +; - Send rally point pokes to a Slack channel per map +; SEND_HISTORY_DISCORD_ENABLED (Syntax: 0 | 1) +; - Send map updates to a Discord channel per map +; SEND_RALLY_DISCORD_ENABLED (Syntax: 0 | 1) +; - Send rally point pokes to a Discord channel per map +; SEND_RALLY_Mail_ENABLED (Syntax: 0 | 1) +; - Send rally point pokes by mail +; - see: [PATHFINDER.NOTIFICATION] section below +[PATHFINDER.MAP.PRIVATE] +LIFETIME = 60 +MAX_COUNT = 5 +MAX_SHARED = 10 +MAX_SYSTEMS = 50 +LOG_ACTIVITY_ENABLED = 1 +LOG_HISTORY_ENABLED = 1 +SEND_HISTORY_SLACK_ENABLED = 0 +SEND_RALLY_SLACK_ENABLED = 1 +SEND_HISTORY_DISCORD_ENABLED = 0 +SEND_RALLY_DISCORD_ENABLED = 1 +SEND_RALLY_Mail_ENABLED = 0 + +[PATHFINDER.MAP.CORPORATION] +LIFETIME = 99999 +MAX_COUNT = 7 +MAX_SHARED = 4 +MAX_SYSTEMS = 100 +LOG_ACTIVITY_ENABLED = 1 +LOG_HISTORY_ENABLED = 1 +SEND_HISTORY_SLACK_ENABLED = 1 +SEND_RALLY_SLACK_ENABLED = 1 +SEND_HISTORY_DISCORD_ENABLED = 1 +SEND_RALLY_DISCORD_ENABLED = 1 +SEND_RALLY_Mail_ENABLED = 0 + +[PATHFINDER.MAP.ALLIANCE] +LIFETIME = 99999 +MAX_COUNT = 4 +MAX_SHARED = 2 +MAX_SYSTEMS = 100 +LOG_ACTIVITY_ENABLED = 1 +LOG_HISTORY_ENABLED = 1 +SEND_HISTORY_SLACK_ENABLED = 1 +SEND_RALLY_SLACK_ENABLED = 1 +SEND_HISTORY_DISCORD_ENABLED = 1 +SEND_RALLY_DISCORD_ENABLED = 1 +SEND_RALLY_Mail_ENABLED = 0 + +; Route search ==================================================================================== +[PATHFINDER.ROUTE] +; Search depth for system route search +; Recursive search depth for search algorithm. +; This is only used in case ESIs /route/ API responds with errors and the custom search algorithm is used. +; Hint: Higher values can lead to high CPU load. If to low, routes might not be found even if exist. +; Syntax: Integer +; Default: 9000 +SEARCH_DEPTH = 9000 + +; Initial count of routes that will be checked when a system becomes active +; Syntax: Integer +; Default: 4 +SEARCH_DEFAULT_COUNT = 10 + +; Max count of routes that can be selected in 'route settings' dialog +; Syntax: Integer +; Default: 6 +MAX_DEFAULT_COUNT = 10 + +; Max count of routes that will be checked (MAX_DEFAULT_COUNT + custom routes) +; Syntax: Integer +; Default: 8 +LIMIT = 12 + +; Email notifications ============================================================================= +[PATHFINDER.NOTIFICATION] +; Email address for rally point pokes +; Requires SMTP configuration (see environment.ini). +; Hint: This only makes sens if the installation is restricted to allied groups only. +; This email address is used for all maps on this installation. +; Syntax: String +; Default: +RALLY_SET = + +; TIMER =========================================================================================== +; Timer values should NOT be changed unless you know what they affect! +; ================================================================================================= +[PATHFINDER.TIMER] +; Login time for characters. Users get logged out after X minutes +; Hint: Set to 0 disables login time and characters stay logged in until Cookie data expires +; Syntax: Integer (minutes) +; Default: 480 +LOGGED = 480 + +; Double click timer +; Syntax: Integer (milliseconds) +; Default: 250 +DBL_CLICK = 250 + +; Time for status change visibility in header +; Syntax: Integer (milliseconds) +; Default: 5000 +PROGRAM_STATUS_VISIBLE = 5000 + +[PATHFINDER.TIMER.UPDATE_SERVER_MAP] +; Map data update interval (ajax long polling) +; This is not used for 'WebSocket' configured installations. +; Syntax: Integer (milliseconds) +; Default: 5000 +DELAY = 5000 + +; Execution limit for map data update request (ajax long polling) +; Requests that exceed the limit are logged as 'warning'. +; Syntax: Integer (milliseconds) +; Default: 200 +EXECUTION_LIMIT = 500 + +[PATHFINDER.TIMER.UPDATE_CLIENT_MAP] +; Execution limit for client side (javascript) map data updates +; Map data updates that exceed the limit are logged as 'warning'. +; Syntax: Integer (milliseconds) +; Default: 50 +EXECUTION_LIMIT = 100 + +[PATHFINDER.TIMER.UPDATE_SERVER_USER_DATA] +; User data update interval (ajax long polling) +; This is not used for 'WebSocket' configured installations. +; Syntax: Integer (milliseconds) +; Default: 5000 +DELAY = 5000 + +; Execution limit for user data update request (ajax long polling) +; Requests that exceed the limit are logged as 'warning'. +; Syntax: Integer (milliseconds) +; Default: 500 +EXECUTION_LIMIT = 1000 + +; update client user data (milliseconds) +[PATHFINDER.TIMER.UPDATE_CLIENT_USER_DATA] +; Execution limit for client side (javascript) user data updates +; User data updates that exceed the limit are logged as 'warning'. +; Syntax: Integer (milliseconds) +; Default: 50 +EXECUTION_LIMIT = 100 + +; CACHE =========================================================================================== +[PATHFINDER.CACHE] +; Checks "character log" data by cronjob after x seconds +; If character is ingame offline -> delete "character log" +; Syntax: Integer (seconds) +; Default: 180 +CHARACTER_LOG_INACTIVE = 180 + +; Max expire time for cache files +; Files will be deleted by cronjob afterwards. +; This setting only affects 'file cache'. Redis installations are not affected by this. +; Syntax: Integer (seconds) +; Default: 864000 (10d) +EXPIRE_MAX = 864000 + +; Expire time for EOL (end of life) connections +; EOL connections get auto deleted by cronjob afterwards. +; Syntax: Integer (seconds) +; Default: 15300 (4h + 15min) +EXPIRE_CONNECTIONS_EOL = 15300 + +; Expire time for WH connections +; WH connections get auto deleted by cronjob afterwards. +; This can be overwritten for each map in the UI. +; Syntax: Integer (seconds) +; Default: 172800 (2d) +EXPIRE_CONNECTIONS_WH = 172800 + +; Expire time for signatures (inactive systems) +; Signatures get auto deleted by cronjob afterwards. +; This can be overwritten for each map in the UI. +; Syntax: Integer (seconds) +; Default: 259200 (3d) +EXPIRE_SIGNATURES = 259200 + +; LOGGING ========================================================================================= +; Log file configurations +; Log files are location in [PATHFINDER]/logs/ dir (see: config.ini) +; Syntax: String +[PATHFINDER.LOGFILES] +; Error log +ERROR = error +; SSO error log +SSO = sso +; Login info +CHARACTER_LOGIN = character_login +; Character access +CHARACTER_ACCESS = character_access +; Session warnings (mysql sessions only) +SESSION_SUSPECT = session_suspect +; Account deleted +DELETE_ACCOUNT = account_delete +; Admin action (e.g. kick, ban) +ADMIN = admin +; TCP socket errors +SOCKET_ERROR = socket_error +; debug log for development +DEBUG = debug + +[PATHFINDER.HISTORY] +; cache time for parsed history log file data +; Syntax: Integer (seconds) +; Default: 5 +CACHE = 5 + +; File folder for 'history' logs (e.g. map history) +; Syntax: String +; Default: history/ +LOG = history/ + +; Max file size for 'history' logs before getting truncated by cronjob +; Syntax: Integer (MB) +; Default: 2 +LOG_SIZE_THRESHOLD = 2 + +; log entries (lines) after file getting truncated by cronjob +; Syntax: Integer +; Default: 1000 +LOG_LINES = 1000 + +; ADMIN =========================================================================================== +; "SUPER" admins and additional "CORPORATION" admins can be added here +;[PATHFINDER.ROLES] +;CHARACTER.0.ID = 123456789 +;CHARACTER.0.ROLE = SUPER +;CHARACTER.1.ID = 1122334455 +;CHARACTER.1.ROLE = CORPORATION + +; API ============================================================================================= +[PATHFINDER.API] +CCP_IMAGE_SERVER = https://images.evetech.net +Z_KILLBOARD = https://zkillboard.com/api +EVEEYE = https://eveeye.com +DOTLAN = http://evemaps.dotlan.net +ANOIK = http://anoik.is +EVE_SCOUT = https://www.eve-scout.com/api +; GitHub Developer API +GIT_HUB = https://api.github.com + +; EXPERIMENTAL [BETA] ============================================================================= +; Use these settings with caution! +; They are currently under testing and might be removed in further releases. +[PATHFINDER.EXPERIMENTS] +; Try to use persistent database connections +; PDO connections get initialized with ATTR_PERSISTENT => true . +; http://php.net/manual/en/pdo.connections.php#example-1030 +; Hint: Set 'wait_timeout' to a high value in your my.conf to keep them open +; Syntax: 0 | 1 +; Default: 0 +PERSISTENT_DB_CONNECTIONS = 1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..589788b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +version: "3.8" + +services: + caddy: + image: lucaslorentz/caddy-docker-proxy:ci-alpine + container_name: caddy + ports: + - 80:80 + - 443:443 + networks: + - caddy + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # this volume is needed to keep the certificates + # otherwise, new ones will be re-issued upon restart + - caddy_data:/data + labels: + caddy.email: carl.egal@gmail.com + pfdb: + image: bianjp/mariadb-alpine:latest + environment: + MYSQL_ROOT_PASSWORD: $MYSQL_PASSWORD + container_name: "$CONTAINER_NAME-db" + networks: + pf: + aliases: + - "${CONTAINER_NAME}db" + volumes: + - db_data:/var/lib/mysql + - $path/pathfinder/export/sql/eve_universe.sql.zip:/eve_universe.sql.zip + restart: always + pf-redis: + image: redis:latest + container_name: "$CONTAINER_NAME-redis" + command: ["redis-server", "--appendonly", "yes"] + hostname: redis + volumes: + - redis_data:/data + networks: + pf: + aliases: + - "$CONTAINER_NAME-redis" + logging: + driver: none + restart: always + pf-socket: + image: composer:latest + container_name: "$CONTAINER_NAME-socket" + command: ["sh","-c","composer install && php cmd.php --tcpHost 0.0.0.0"] + hostname: socket + volumes: + - ${path}/websocket:/app + networks: + pf: + aliases: + - "$CONTAINER_NAME-socket" + restart: always + pf: + container_name: ${CONTAINER_NAME} + hostname: "pathfinder" + build: '.' + env_file: + - .env + labels: + caddy: test.goryn.wtf + caddy.reverse_proxy: "{{upstreams 80}}" + networks: + - pf + - caddy +# ports: +# - 80:80 +# - 8030:8030 + healthcheck: + disable: true + volumes: + - ${path}/config/pathfinder/config.ini:/var/www/html/pathfinder/app/templateConfig.ini + - ${path}/config/pathfinder/pathfinder.ini:/var/www/html/pathfinder/app/pathfinder.ini + - ${path}/config/pathfinder/plugins.ini:/var/www/html/pathfinder/app/plugins.ini + depends_on: + - pfdb + - pf-redis + - pf-socket + restart: always + +volumes: + data: + db_data: + redis_data: + caddy_data: {} +networks: + pf: + caddy: + diff --git a/pathfinder b/pathfinder new file mode 160000 index 0000000..c3f0bb2 --- /dev/null +++ b/pathfinder @@ -0,0 +1 @@ +Subproject commit c3f0bb2eccfd39cb5cd2eee37db3f005e1718630 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..040d380 --- /dev/null +++ b/setup.sh @@ -0,0 +1,128 @@ +#!/bin/bash +. static/menu.sh +source $CWD\.env + + +#https://bytefreaks.net/gnulinux/bash/cecho-a-function-to-print-using-different-colors-in-bash +cecho () { + declare -A colors; + colors=(\ + ['black']='\E[0;47m'\ + ['red']='\E[0;31m'\ + ['green']='\E[0;32m'\ + ['yellow']='\E[0;33m'\ + ['blue']='\E[0;34m'\ + ['magenta']='\E[0;35m'\ + ['cyan']='\E[0;36m'\ + ['white']='\E[0;37m'\ + ); + + + + local defaultMSG=""; + local defaultColor="black"; + local defaultNewLine=true; + + + + while [[ $# -gt 1 ]]; + do + key="$1"; + + + + case $key in + -c|--color) + color="$2"; + shift; + ;; + + -n|--noline) + newLine=false; + ;; + *) + # unknown option + ;; + esac + shift; + done + + message=${1:-$defaultMSG}; # Defaults to default message. + color=${color:-$defaultColor}; # Defaults to default color, if not specified. + newLine=${newLine:-$defaultNewLine}; + + echo -en "${colors[$color]}"; + echo -en "$message"; + if [ "$newLine" = true ] ; then + echo; + fi + tput sgr0; # Reset text attributes to normal without clearing screen. + return; +} + + +function wizzard { + cecho -c 'blue' "$@"; + #echo -e "\e[4mMENU: select-one, using assoc keys, preselection, leave selected options\e[24m" + #declare -A options2="${$2}" + #declare -A options2=( [foo]="Hallo" [bar]="World" [baz]="Record") + ui_widget_select -l -k "${!menu[@]}" -s bar -i "${menu[@]}" + #echo "Return code: $?" + #return "$?" +} + +function editVariable(){ +if [ "$1" == "" ]; then + read -p "Please set a config value for $3 [$2]: " VALUE + VALUE="${VALUE:-$2}" + #sed -i "s/$3=.*/$3=\"$VALUE\"/g" .env + sed -i "s@$3=.*@$3=\"$VALUE\"@g" .env + #sed -i 's/'"$3"'=.*/'"$3"'='"${VALUE}"'/' .env + #sed -i -e 's/'$3'=.*/'$3'="'"$VALUE"'"/g' .env + #sed -i "s/$3=.*/$3=$VALUE/g" .env +fi +} +function setConfig(){ +editVariable "$path" "$PWD" "path" +editVariable "$CONTAINER_NAME" "pf" "CONTAINER_NAME" +editVariable "$DOMAIN" "localhost" "DOMAIN" +editVariable "$SERVER_NAME" "CARLFINDER" "SERVER_NAME" +editVariable "$MYSQL_PASSWORD" "" "MYSQL_PASSWORD" +editVariable "$CCP_SSO_CLIENT_ID" "" "CCP_SSO_CLIENT_ID" +editVariable "$CCP_SSO_SECRET_KEY" "" "CCP_SSO_SECRET_KEY" +editVariable "$CCP_ESI_SCOPES" "esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1,esi-universe.read_structures.v1,esi-corporations.read_corporation_membership.v1,esi-clones.read_clones.v1" "CCP_ESI_SCOPES" +source $CWD\.env +} + +while [[ $path == "" ]] || [[ $CONTAINER_NAME == "" ]] || [[ $DOMAIN == "" ]] || [[ $SERVER_NAME == "" ]] || [[ $MYSQL_PASSWORD == "" ]] || [[ $CCP_SSO_CLIENT_ID == "" ]] || [[ $CCP_SSO_SECRET_KEY == "" ]] || [[ $CCP_ESI_SCOPES == "" ]]; do + setConfig +done + +docker container inspect $CONTAINER_NAME > /dev/null 2>&1; +if [ $? -eq 1 ]; +then + declare -A menu=( [no]="NO" [yes]="YES") + wizzard "You did not build the container ! Do you want the setup to do it ? " + if [ ${menu[$UI_WIDGET_RC]} == "YES" ]; then + docker-compose build + tput clear; + fi +fi + +running=$(docker container inspect -f '{{.State.Status}}' $CONTAINER_NAME) +if [[ $running != "running" ]];then + declare -A menu=( [no]="NO" [yes]="YES") + wizzard "Do you want to run the container ?" + if [ ${menu[$UI_WIDGET_RC]} == "YES" ]; then + docker-compose up -d + fi +fi + +declare -A menu=( [no]="NO" [yes]="YES ") + cecho -c 'blue' "Do you want to import the eve_universe database ?"; + cecho -c 'red' "DISCLAIMER: Before you do that go to http://$DOMAIN/setup page (USERNAME:'pf' & password is your APP_PASSWORD) and hit create database, setup tables & fix column keys. After you did that select YES." + wizzard ""; +if [ ${menu[$UI_WIDGET_RC]} == "YES" ]; then +docker-compose exec pfdb /bin/sh -c "unzip -p eve_universe.sql.zip | mysql -u root -p\$MYSQL_ROOT_PASSWORD eve_universe"; +fi + diff --git a/static/crontab.txt b/static/crontab.txt new file mode 100755 index 0000000..6573e4b --- /dev/null +++ b/static/crontab.txt @@ -0,0 +1,2 @@ +* * * * * cd /var/www/html/pathfinder;sudo -u nobody php index.php /cron >> /var/log/cron.log 2>&1 + diff --git a/static/entrypoint.sh b/static/entrypoint.sh new file mode 100644 index 0000000..71e8905 --- /dev/null +++ b/static/entrypoint.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e +envsubst '$DOMAIN' /etc/nginx/sites_enabled/site.conf +envsubst '$CONTAINER_NAME' /etc/nginx/nginx.conf +envsubst /var/www/html/pathfinder/app/environment.ini +envsubst /var/www/html/pathfinder/app/config.ini +envsubst /etc/php7/conf.d/zzz_custom.ini +htpasswd -c -b -B /etc/nginx/.setup_pass pf "$APP_PASSWORD" +exec "$@" diff --git a/static/menu.sh b/static/menu.sh new file mode 100644 index 0000000..b1d8061 --- /dev/null +++ b/static/menu.sh @@ -0,0 +1,451 @@ +#!/bin/bash + +## +# Pure BASH interactive CLI/TUI menu (single and multi-select/checkboxes) +# +# Author: Markus Geiger +# Last revised 2011-09-11 +# +# ATTENTION! TO BE REFACTORED! FIRST DRAFT! +# +# Demo +# +# - ASCIINEMA +# https://asciinema.org/a/Y4hLxnN20JtAlrn3hsC6dCRn8 +# +# Inspired by +# +# - https://serverfault.com/questions/144939/multi-select-menu-in-bash-script +# - Copyright (C) 2017 Ingo Hollmann - All Rights Reserved +# https://www.bughunter2k.de/blog/cursor-controlled-selectmenu-in-bash +# +# Notes +# +# - This is a hacky first implementation for my shell tools/dotfiles (ZSH) +# - Intention is to use it for CLI wizards (my aim is NOT a full blown curses TUI window interface) +# - I concerted TPUT to ANSII-sequences to spare command executions (e.g. `tput ed | xxd`) +# reference: http://linuxcommand.org/lc3_adv_tput.php +# +# Permission to copy and modify is granted under the Creative Commons Attribution 4.0 license +# + +# Strict bash scripting (not yet) +# set -euo pipefail -o errtrace + + +# Templates for ui_widget_select +declare -xr UI_WIDGET_SELECT_TPL_SELECTED='\e[33m → %s \e[39m' +declare -xr UI_WIDGET_SELECT_TPL_DEFAULT=" \e[37m%s %s\e[39m" +declare -xr UI_WIDGET_MULTISELECT_TPL_SELECTED="\e[33m → %s %s\e[39m" +declare -xr UI_WIDGET_MULTISELECT_TPL_DEFAULT=" \e[37m%s %s\e[39m" +declare -xr UI_WIDGET_TPL_CHECKED="▣" +declare -xr UI_WIDGET_TPL_UNCHECKED="□" + +# We use env variable to pass results since no interactive output from subshells and we don't wanna go hacky! +declare -xg UI_WIDGET_RC=-1 + + +## +# Get type of a BASH variable (BASH ≥v4.0) +# +# Notes +# - if references are encountered it will automatically try +# to resolve them unless '-f' is passed! +# - resolving functions can be seen as bonus since they also +# use `declare` (but with -fF). this behavior should be removed! +# - bad indicates bad referencing which normally shouldn't occur! +# - types are shorthand and associative arrays map to "map" for convenience +# +# argument +# -f (optional) force resolvement of first hit +# Variable name +# +# stdout +# (nil|number|array|map|reference) +# +# stderr +# - +# +# return +# 0 - always +typeof() { + # __ref: avoid local to overwrite global var declaration and therefore emmit wrong results! + local type="" resolve_ref=true __ref="" signature=() + if [[ "$1" == "-f" ]]; then + # do not resolve reference + resolve_ref=false; shift; + fi + __ref="$1" + while [[ -z "${type}" ]] || ( ${resolve_ref} && [[ "${type}" == *n* ]] ); do + IFS=$'\x20\x0a\x3d\x22' && signature=($(declare -p "$__ref" 2>/dev/null || echo "na")) + if [[ ! "${signature}" == "na" ]]; then + type="${signature[1]}" # could be -xn! + fi + if [[ -z "${__ref}" ]] || [[ "${type}" == "na" ]] || [[ "${type}" == "" ]]; then + printf "nil" + return 0 + elif [[ "${type}" == *n* ]]; then + __ref="${signature[4]}" + fi + done + case "$type" in + *i*) printf "number";; + *a*) printf "array";; + *A*) printf "map";; + *n*) printf "reference";; + *) printf "string";; + esac +} + + +## +# Removes a value from an array +# +# alternatives +# array=( "${array[@]/$delete}" +# +# arguments +# arg1 value +# arg* list or stdin +# +# stdout +# list with space seperator +array_without_value() { + local args=() value="${1}" s + shift + for s in "${@}"; do + if [ "${value}" != "${s}" ]; then + args+=("${s}") + fi + done + echo "${args[@]}" +} + + +## +# check if a value is in an array +# +# alternatives +# array=( "${array[@]/$delete}" +# +# arguments +# arg1 value +# arg* list or stdin +# +# stdout +# list with space seperator +array_contains_value() { + local e match="$1" + shift + for e; do [[ "$e" == "$match" ]] && return 0; done + return 1 +} + +## +# BASH only string to hex +# +# stdout +# hex squence +str2hex_echo() { + # USAGE: hex_repr=$(str2hex_echo "ABC") + # returns "0x410x420x43" + local str=${1:-$(cat -)} + local fmt="" + local chr + local -i i + printf "0x" + for i in `seq 0 $((${#str}-1))`; do + chr=${str:i:1} + printf "%x" "'${chr}" + done +} + +## +# Read key and map to human readable output +# +# notes +# output prefix (concated by `-`) +# c ctrl key +# a alt key +# c-a ctrl+alt key +# use F if you mean shift! +# uppercase `f` for `c+a` combination is not possible! +# +# arguments +# -d for debugging keycodes (hex output via xxd) +# -l lowercase all chars +# -l timeout +# +# stdout +# mapped key code like in notes +ui_key_input() { + local key + local ord + local debug=0 + local lowercase=0 + local prefix='' + local args=() + local opt + + while (( "$#" )); do + opt="${1}" + shift + case "${opt}" in + "-d") debug=1;; + "-l") lowercase=1;; + "-t") args+=(-t $1); shift;; + esac + done + IFS= read ${args[@]} -rsn1 key 2>/dev/null >&2 + read -sN1 -t 0.0001 k1; read -sN1 -t 0.0001 k2; read -sN1 -t 0.0001 k3 + key+="${k1}${k2}${k3}" + if [[ "${debug}" -eq 1 ]]; then echo -n "${key}" | str2hex_echo; echo -n " : " ;fi; + case "${key}" in + '') key=enter;; + ' ') key=space;; + $'\x1b') key=esc;; + $'\x1b\x5b\x36\x7e') key=pgdown;; + $'\x1b\x5b\x33\x7e') key=erase;; + $'\x7f') key=backspace;; + $'\e[A'|$'\e0A '|$'\e[D'|$'\e0D') key=up;; + $'\e[B'|$'\e0B'|$'\e[C'|$'\e0C') key=down;; + $'\e[1~'|$'\e0H'|$'\e[H') key=home;; + $'\e[4~'|$'\e0F'|$'\e[F') key=end;; + $'\e') key=enter;; + $'\e'?) prefix="a-"; key="${key:1:1}";; + esac + + # only lowercase if we have a single letter + # ctrl key is hidden within char code (no o) + if [[ "${#key}" == 1 ]]; then + ord=$(LC_CTYPE=C printf '%d' "'${key}") + if [[ "${ord}" -lt 32 ]]; then + prefix="c-${prefix}" + # ord=$(([##16] ord + 0x60)) + # let "ord = [##16] ${ord} + 0x60" + ord="$(printf "%X" $((ord + 0x60)))" + key="$(printf "\x${ord}")" + fi + if [[ "${lowercase}" -eq 1 ]]; then + key="${key,,}" + fi + fi + + echo "${prefix}${key}" +} + +## +# UI Widget Select +# +# arguments +# -i <[menu-item(s)] …> menu items +# -m activate multi-select mode (checkboxes) +# -k <[key(s)] …> keys for menu items (if none given indexes are used) +# -s <[selected-keys(s)] …> selected keys (index or key) +# if keys are used selection needs to be keys +# -c clear complete menu on exit +# -l clear menu and leave selections +# +# env +# UI_WIDGET_RC will be selected index or -1 of nothing was selected +# +# stdout +# menu display - don't use subshell since we need interactive shell and use tput! +# +# stderr +# sometimes (trying to clean up) +# +# return +# 0 success +# -1 cancelled +ui_widget_select() { + local menu=() keys=() selection=() selection_index=() + local cur=0 oldcur=0 collect="item" select="one" + local sel="" marg="" drawn=false ref v="" + local opt_clearonexit=false opt_leaveonexit=false + export UI_WIDGET_RC=-1 + while (( "$#" )); do + opt="${1}"; shift + case "${opt}" in + -k) collect="key";; + -i) collect="item";; + -s) collect="selection";; + -m) select="multi";; + -l) opt_clearonexit=true; opt_leaveonexit=true;; + -c) opt_clearonexit=true;; + *) + if [[ "${collect}" == "selection" ]]; then + selection+=("${opt}") + elif [[ "${collect}" == "key" ]]; then + keys+=("${opt}") + else + menu+=("$opt") + fi;; + esac + done + + # sanity check + if [[ "${#menu[@]}" -eq 0 ]]; then + >&2 echo "no menu items given" + return 1 + fi + + if [[ "${#keys[@]}" -gt 0 ]]; then + # if keys are used + # sanity check + if [[ "${#keys[@]}" -gt 0 ]] && [[ "${#keys[@]}" != "${#menu[@]}" ]]; then + >&2 echo "number of keys do not match menu options!" + return 1 + fi + # map keys to indexes + selection_index=() + for sel in "${selection[@]}"; do + for ((i=0;i<${#keys[@]};i++)); do + if [[ "${keys[i]}" == "${sel}" ]]; then + selection_index+=("$i") + fi + done + done + else + # if no keys are used assign by indexes + selection_index=(${selection[@]}) + fi + + clear_menu() { + local str="" + for i in "${menu[@]}"; do str+="\e[2K\r\e[1A"; done + echo -en "${str}" + } + + ## + # draws menu in three different states + # - initial: draw every line as intenden + # - update: only draw updated lines and skip existing + # - exit: only draw selected lines + draw_menu() { + local mode="${initial:-$1}" check=false check_tpl="" str="" msg="" tpl_selected="" tpl_default="" marg=() + + if ${drawn} && [[ "$mode" != "exit" ]]; then + # reset position + str+="\r\e[2K" + for i in "${menu[@]}"; do str+="\e[1A"; done + # str+="${TPUT_ED}" + fi + if [[ "$select" == "one" ]]; then + tpl_selected="$UI_WIDGET_SELECT_TPL_SELECTED" + tpl_default="$UI_WIDGET_SELECT_TPL_DEFAULT" + else + tpl_selected="$UI_WIDGET_MULTISELECT_TPL_SELECTED" + tpl_default="$UI_WIDGET_MULTISELECT_TPL_DEFAULT" + fi + + for ((i=0;i<${#menu[@]};i++)); do + check=false + if [[ "$select" == "one" ]]; then + # single selection + marg=("${menu[${i}]}") + if [[ ${cur} == ${i} ]]; then + check=true + fi + else + # multi-select + check_tpl="$UI_WIDGET_TPL_UNCHECKED"; + if array_contains_value "$i" "${selection_index[@]}"; then + check_tpl="$UI_WIDGET_TPL_CHECKED"; check=true + fi + marg=("${check_tpl}" "${menu[${i}]}") + fi + if [[ "${mode}" != "exit" ]] && [[ ${cur} == ${i} ]]; then + str+="$(printf "\e[2K${tpl_selected}" "${marg[@]}")\n"; + elif ([[ "${mode}" != "exit" ]] && ([[ "${oldcur}" == "${i}" ]] || [[ "${mode}" == "initial" ]])) || (${check} && [[ "${mode}" == "exit" ]]); then + str+="$(printf "\e[2K${tpl_default}" "${marg[@]}")\n"; + elif [[ "${mode}" -eq "update" ]] && [[ "${mode}" != "exit" ]]; then + str+="\e[1B\r" + fi + done + echo -en "${str}" + export drawn=true + } + + # initial draw + draw_menu initial + + # action loop + while true; do + oldcur=${cur} + key=$(ui_key_input) + case "${key}" in + up|left|i|j) ((cur > 0)) && ((cur--));; + down|right|k|l) ((cur < ${#menu[@]}-1)) && ((cur++));; + home) cur=0;; + pgup) let cur-=5; if [[ "${cur}" -lt 0 ]]; then cur=0; fi;; + pgdown) let cur+=5; if [[ "${cur}" -gt $((${#menu[@]}-1)) ]]; then cur=$((${#menu[@]}-1)); fi;; + end) ((cur=${#menu[@]}-1));; + space) + if [[ "$select" == "one" ]]; then + continue + fi + if ! array_contains_value "$cur" "${selection_index[@]}"; then + selection_index+=("$cur") + else + selection_index=($(array_without_value "$cur" "${selection_index[@]}")) + fi + ;; + enter) + if [[ "${select}" == "multi" ]]; then + export UI_WIDGET_RC=() + for i in ${selection_index[@]}; do + if [[ "${#keys[@]}" -gt 0 ]]; then + export UI_WIDGET_RC+=("${keys[${i}]}") + else + export UI_WIDGET_RC+=("${i}") + fi + done + else + if [[ "${#keys[@]}" -gt 0 ]]; then + export UI_WIDGET_RC="${keys[${cur}]}"; + else + export UI_WIDGET_RC=${cur}; + fi + fi + if $opt_clearonexit; then clear_menu; fi + if $opt_leaveonexit; then draw_menu exit; fi + return + ;; + [1-9]) + let "cur = ${key}" + if [[ ${#menu[@]} -gt 9 ]]; then + echo -n "${key}" + sleep 1 + key="$(ui_key_input -t 0.5 )" + if [[ "$key" =~ [0-9] ]]; then + let "cur = cur * 10 + ${key}" + elif [[ "$key" != "enter" ]]; then + echo -en "\e[2K\r$key invalid input!" + sleep 1 + fi + fi + let "cur = cur - 1" + if [[ ${cur} -gt ${#menu[@]}-1 ]]; then + echo -en "\e[2K\rinvalid index!" + sleep 1 + cur="${oldcur}" + fi + echo -en "\e[2K\r" + ;; + esc|q|$'\e') + if $opt_clearonexit; then clear_menu; fi + return 1;; + esac + + # Redraw menu + draw_menu update + done +} + + + + + + + diff --git a/static/nginx/nginx.conf b/static/nginx/nginx.conf new file mode 100755 index 0000000..fbe77d6 --- /dev/null +++ b/static/nginx/nginx.conf @@ -0,0 +1,247 @@ +# nginx Configuration File +# http://wiki.nginx.org/Configuration + +# Run as a less privileged user for security reasons. +user nobody nobody; + +# How many worker threads to run; +# "auto" sets it to the number of CPU cores available in the system, and +# offers the best performance. Don't set it higher than the number of CPU +# cores if changing this parameter. + +# The maximum number of connections for Nginx is calculated by: +# max_clients = worker_processes * worker_connections +# (2 Cores = 4 processes) check cores: grep processor /proc/cpuinfo | wc -l +#worker_processes auto; +worker_processes 4; + +# Maximum open file descriptors per process; +# should be > worker_connections. +worker_rlimit_nofile 20000; + +events { + # The worker_connections command tells our worker processes how many people can simultaneously be served by Nginx. + # When you need > 8000 * cpu_cores connections, you start optimizing your OS, + # and this is probably the point at which you hire people who are smarter than + # you, as this is *a lot* of requests. + # worker_connections 768; + worker_connections 19000; + multi_accept on; + use epoll; +} + +# Default error log file +# (this is only used when you don't override error_log on a server{} level) +error_log /var/lib/nginx/error.log warn; +pid /var/run/nginx.pid; + + +http { + + # Hide nginx version information. + server_tokens on; + + # Define the MIME types for files. + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Update charset_types due to updated mime.types + charset_types text/css text/plain text/vnd.wap.wml application/javascript application/json application/rss+xml application/xml; + + # Speed up file transfers by using sendfile() to copy directly + # between descriptors rather than using read()/write(). + # For performance reasons, on FreeBSD systems w/ ZFS + # this option should be disabled as ZFS's ARC caches + # frequently used files in RAM by default. + sendfile on; + + # Tell Nginx not to send out partial frames; this increases throughput + # since TCP frames are filled up before being sent out. (adds TCP_CORK) + tcp_nopush off; + + # Send packages immediately (on). Otherwise nginx will "wait" 200ms for additional data to fullfill a package. + tcp_nodelay on; + + # Timeouts ================================================================================================================== + + # 'Body' and 'Header' max response timings. If neither a body or header is sent, the server will issue a 408 error or Request time out. (Default: 60s) + client_body_timeout 12; + client_header_timeout 12; + + # Assigns the timeout for keep-alive connections with the client. + # Simply put, Nginx will close connections with the client after this period of time.(Default: 65) + keepalive_timeout 20s; + + # Finally, the send_timeout is established not on the entire transfer of answer, but only between two operations of reading; + # if after this time client will take nothing, then Nginx is shutting down the connection. + send_timeout 10s; + + # Sets a timeout for name resolution. (Default: 30s) + resolver_timeout 5s; + + # Timeout period for connection with FastCGI-server. It should be noted that this value can't exceed 75 seconds. (Default: 60s) + fastcgi_connect_timeout 5s; + + # Amount of time for upstream to wait for a fastcgi process to send data. + # Change this directive if you have long running fastcgi processes that do not produce output until they have finished processing. + # If you are seeing an upstream timed out error in the error log, then increase this parameter to something more appropriate. (Default: 60s) + fastcgi_read_timeout 40s; + + # Request timeout to the server. The timeout is calculated between two write operations, not for the whole request. + # If no data have been written during this period then serve closes the connection. (Default: 60s) + fastcgi_send_timeout 15s; + + # WebSockets =============================================================================================================== + + + + + # Buffer ==================================================================================================================== + + # Similar to the previous directive, only instead it handles the client header size. + # For all intents and purposes, 1K is usually a decent size for this directive. + client_header_buffer_size 1k; + + # The maximum number and size of buffers for large client headers. + large_client_header_buffers 4 4k; + + # The maximum allowed size for a client request. If the maximum size is exceeded, then Nginx will spit out a 413 error or Request Entity Too Large. (Default: 1m) + # php max upload limit cannot be larger than this + client_max_body_size 8m; + + # This handles the client buffer size, meaning any POST actions sent to Nginx. POST actions are typically form submissions. + client_body_buffer_size 32k; + + output_buffers 2 32k; + + fastcgi_buffering on; + fastcgi_buffers 8 32k; + fastcgi_buffer_size 32k; + + # Caching ================================================================================================================== + + # Above sample tells nginx to cache a file information as long as minimum 2 requests are made during 5m window. + open_file_cache max=10000 inactive=5m; + open_file_cache_valid 2m; + open_file_cache_min_uses 1; + open_file_cache_errors on; + + # Fast CGI + # fastcgi_cache_path /etc/nginx/cache levels=1:2 keys_zone=MYAPP:100m inactive=60m; + # fastcgi_cache_key "$scheme$request_method$host$request_uri"; + + # Logging =================================================================================================================== + + # Format to use in log files + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + # Extended Logging (e.g. for Nginx Aplify log graphs) + log_format main_ext '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + '"$host" sn="$server_name" ' + 'rt=$request_time ' + 'ua="$upstream_addr" us="$upstream_status" ' + 'ut="$upstream_response_time" ul="$upstream_response_length" ' + 'cs=$upstream_cache_status' ; + + # This excludes 2xx and 3xx status codes from beeing loged + map $status $loggable { + ~^[23] 0; + default 1; + } + + # logs just 5xxx errors + map $status $log_production { + ~^[1234] 0; + default 1; + } + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream websocket { + server ${CONTAINER_NAME}-socket:8020; + } + + + + + # Default log file + # (this is only used when you don't override access_log on a server{} level) + access_log /var/log/nginx/access.log main if=$loggable; + + # Compression =============================================================================================================== + + # Enable Gzip compressed. + gzip on; + + # Compression level (1-9). + # 5 is a perfect compromise between size and cpu usage, offering about + # 75% reduction for most ascii files (almost identical to level 9). + gzip_comp_level 5; + + # Don't compress anything that's already small and unlikely to shrink much + # if at all (the default is 20 bytes, which is bad as that usually leads to + # larger files after gzipping). + gzip_min_length 256; + + # Compress data even for clients that are connecting to us via proxies, + # identified by the "Via" header (required for CloudFront). + # gzip_proxied expired no-cache no-store private auth; + gzip_proxied any; + + # Tell proxies to cache both the gzipped and regular version of a resource + # whenever the client's Accept-Encoding capabilities header varies; + # Avoids the issue where a non-gzip capable client (which is extremely rare + # today) would display gibberish if their proxy gave them the gzipped version. + gzip_vary on; + + # Compress all output labeled with one of the following MIME-types. + gzip_types + application/atom+xml + application/javascript + application/json + application/ld+json + application/manifest+json + application/rss+xml + application/vnd.geo+json + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + application/xhtml+xml + application/xml + font/opentype + image/bmp + image/svg+xml + image/x-icon + text/cache-manifest + text/css + text/plain + text/vcard + text/vnd.rim.location.xloc + text/vtt + text/x-component + text/x-cross-domain-policy; + # text/html; + + # This should be turned on if you are going to have pre-compressed copies (.gz) of + # static files available. If not it should be left off as it will cause extra I/O + # for the check. It is best if you enable this in a location{} block for + # a specific directory, or on an individual server{} level. + gzip_static off; + + proxy_buffers 16 16k; + proxy_buffer_size 16k; + + # Include files in the sites-enabled folder. server{} configuration files should be + # placed in the sites-available folder, and then the configuration should be enabled + # by creating a symlink to it in the sites-enabled folder. + # See doc/sites-enabled.md for more info. + # include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites_enabled/*.conf; +} diff --git a/static/nginx/site.conf b/static/nginx/site.conf new file mode 100755 index 0000000..621ca2a --- /dev/null +++ b/static/nginx/site.conf @@ -0,0 +1,89 @@ +server { + listen 80; + listen [::]:80; + #listen [::]:80 default_server ipv6only=on; + server_name $DOMAIN; + + + + + # Path to static files + root /var/www/html/pathfinder/; + index index.php index.html index.htm; + + # Specify a charset + charset utf-8; + + + # Logging =================================================================================================================== + + + location = /setup { + auth_basic "Setup Login"; + auth_basic_user_file /etc/nginx/.setup_pass; + try_files $uri $uri/ /index.php?$query_string; + } + + location / { + # First attempt to serve request as file, then + # as directory, then fall back to index.php + try_files $uri $uri/ /index.php?q=$uri&$args; + } + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /var/lib/nginx/html; + } + + # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 + # + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param SCRIPT_NAME $fastcgi_script_name; + fastcgi_index index.php; + include fastcgi_params; + } + + location ~* \.(jpg|jpeg|gif|png|css|js|ico|xml)$ { + expires 5d; + } + + # deny access to . files, for security + # + location ~ /\. { + log_not_found off; + deny all; + } + + location /ws/map/update { + proxy_pass http://websocket; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 8h; + proxy_send_timeout 5s; + proxy_connect_timeout 3s; + proxy_buffering off; + } + # static sources + location /public/ { + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 10s; + sendfile_max_chunk 512k; + } + +} diff --git a/static/pathfinder/environment.ini b/static/pathfinder/environment.ini new file mode 100644 index 0000000..7c99c5f --- /dev/null +++ b/static/pathfinder/environment.ini @@ -0,0 +1,60 @@ +; Environment Config + +[ENVIRONMENT] +; project environment (DEVELOP || PRODUCTION). +; This effects: DB connection, Mail-Server, SSO, ESI configurations in this file +; configuration below +SERVER = PRODUCTION + +[ENVIRONMENT.PRODUCTION] +; path to index.php (Default: leave blank == "auto-detect") +; -> e.g. set /pathfinder if your URL looks like https://www.[YOUR_DOMAIN]/pathfinder (subfolder) +BASE = +; deployment URL (e.g. https://www.pathfinder-w.space) +URL = {{@SCHEME}}://$DOMAIN +; level of debug/error stack trace +DEBUG = 0 +; Pathfinder database +DB_PF_DNS = mysql:host=${CONTAINER_NAME}db;port=3306;dbname= +DB_PF_NAME = pf +DB_PF_USER = root +DB_PF_PASS = $MYSQL_PASSWORD + +; Universe data (New Eden) cache DB for ESI API respons +DB_UNIVERSE_DNS = mysql:host=${CONTAINER_NAME}db;port=3306;dbname= +DB_UNIVERSE_NAME = eve_universe +DB_UNIVERSE_USER = root +DB_UNIVERSE_PASS = $MYSQL_PASSWORD + + +; EVE-Online CCP Database export +DB_CCP_DNS = mysql:host=${CONTAINER_NAME}db;port=3306;dbname= +DB_CCP_NAME = eve_lifeblood_min +DB_CCP_USER = root +DB_CCP_PASS = $MYSQL_PASSWORD + +; CCP SSO +CCP_SSO_URL = https://login.eveonline.com +CCP_SSO_CLIENT_ID = $CCP_SSO_CLIENT_ID +CCP_SSO_SECRET_KEY = $CCP_SSO_SECRET_KEY +CCP_SSO_DOWNTIME = 11:00 + +; CCP ESI API +CCP_ESI_URL = https://esi.evetech.net +CCP_ESI_DATASOURCE = tranquility +CCP_ESI_SCOPES = $CCP_ESI_SCOPES +CCP_ESI_SCOPES_ADMIN = + +; SMTP settings (optional) +SMTP_HOST = localhost +SMTP_PORT = 25 +SMTP_SCHEME = TLS +SMTP_USER = +SMTP_PASS = + +SMTP_FROM = registration@pathfinder-w.space +SMTP_ERROR = admin@pathfinder-w.space + +; TCP Socket configuration (optional) (advanced) +SOCKET_HOST = ${CONTAINER_NAME}-socket +SOCKET_PORT = 5555 diff --git a/static/pathfinder/routes.ini b/static/pathfinder/routes.ini new file mode 100644 index 0000000..cfe2868 --- /dev/null +++ b/static/pathfinder/routes.ini @@ -0,0 +1,27 @@ +; Route config + +[routes] +; DB setup setup +; IMPORTANT: remove/comment this line after setup/update is finished! +GET @setup: /setup [sync] = {{ @NAMESPACE }}\Controller\Setup->init +; login (index) page +GET @login: / [sync] = {{ @NAMESPACE }}\Controller\AppController->init +; CCP SSO redirect +GET @sso: /sso/@action [sync] = {{ @NAMESPACE }}\Controller\Ccp\Sso->@action +; map page +GET @map: /map* [sync] = {{ @NAMESPACE }}\Controller\MapController->init +; admin panel +GET @admin: /admin* [sync] = {{ @NAMESPACE }}\Controller\Admin->dispatch + +; AJAX API wildcard endpoints (not cached, throttled) +GET|POST /api/@controller/@action [ajax] = {{ @NAMESPACE }}\Controller\Api\@controller->@action, 0, 512 +GET|POST /api/@controller/@action/@arg1 [ajax] = {{ @NAMESPACE }}\Controller\Api\@controller->@action, 0, 512 +GET|POST /api/@controller/@action/@arg1/@arg2 [ajax] = {{ @NAMESPACE }}\Controller\Api\@controller->@action, 0, 512 + +; onUnload route or final map sync (@see https://developer.mozilla.org/docs/Web/API/Navigator/sendBeacon) +POST /api/Map/updateUnloadData = {{ @NAMESPACE }}\Controller\Api\Map->updateUnloadData, 0, 512 + +[maps] +; REST API wildcard endpoints (not cached, throttled) +/api/rest/@controller* [ajax] = {{ @NAMESPACE }}\Controller\Api\Rest\@controller, 0, 512 +/api/rest/@controller/@id [ajax] = {{ @NAMESPACE }}\Controller\Api\Rest\@controller, 0, 512 \ No newline at end of file diff --git a/static/php/fpm-pool.conf b/static/php/fpm-pool.conf new file mode 100755 index 0000000..9ab859f --- /dev/null +++ b/static/php/fpm-pool.conf @@ -0,0 +1,40 @@ +[global] +; Log to stderr +error_log = /dev/stderr + +[www] +user = nobody +group = nobody +; Enable status page +pm.status_path = /fpm-status + +; Ondemand process manager +pm = ondemand + +; The number of child processes to be created when pm is set to 'static' and the +; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'. +; This value sets the limit on the number of simultaneous requests that will be +; served. Equivalent to the ApacheMaxClients directive with mpm_prefork. +; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP +; CGI. The below defaults are based on a server without much resources. Don't +; forget to tweak pm.* to fit your needs. +; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand' +; Note: This value is mandatory. +pm.max_children = 50 + +; The number of seconds after which an idle process will be killed. +; Note: Used only when pm is set to 'ondemand' +; Default Value: 10s +pm.process_idle_timeout = 10s; + +; The number of requests each child process should execute before respawning. +; This can be useful to work around memory leaks in 3rd party libraries. For +; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS. +; Default Value: 0 +pm.max_requests = 500 + +; Make sure the FPM workers can reach the environment variables for configuration +clear_env = no + +; Catch output from PHP +catch_workers_output = yes diff --git a/static/php/php.ini b/static/php/php.ini new file mode 100755 index 0000000..eedddba --- /dev/null +++ b/static/php/php.ini @@ -0,0 +1,14 @@ +upload_max_filesize = 100M +post_max_size = 108M +max_input_vars = 3000 +html_errors = 0 +cgi.force_redirect=0 +cgi.fix_pathinfo=1 +fastcgi.impersonate=1 +fastcgi.logging=0 +request_terminate_timeout = 300 +session.save_handler = redis +session.save_path = "tcp://${CONTAINER_NAME}-redis:6379" +[Date] +date.timezone="UTC" + diff --git a/static/supervisord.conf b/static/supervisord.conf new file mode 100755 index 0000000..37067b2 --- /dev/null +++ b/static/supervisord.conf @@ -0,0 +1,24 @@ +[supervisord] +nodaemon=true +[program:php-fpm] +command=php-fpm7 -F +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +autorestart=true +startretries=0 + +[program:nginx] +command=nginx -g 'daemon off;' +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +autorestart=true +startretries=0 + +[program:cron] +command = /usr/sbin/crond -f -l 8 +autostart=true +autorestart=true diff --git a/websocket/.gitattributes b/websocket/.gitattributes new file mode 100644 index 0000000..bdb0cab --- /dev/null +++ b/websocket/.gitattributes @@ -0,0 +1,17 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Custom for Visual Studio +*.cs diff=csharp + +# Standard to msysgit +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/websocket/.gitignore b/websocket/.gitignore new file mode 100644 index 0000000..1f1701e --- /dev/null +++ b/websocket/.gitignore @@ -0,0 +1,11 @@ +# Created by .ignore support plugin (hsz.mobi) +### Example user template template +### Example user template + +# IntelliJ project files +.idea +out +gen + +vendor + diff --git a/websocket/README.md b/websocket/README.md new file mode 100644 index 0000000..fc098f1 --- /dev/null +++ b/websocket/README.md @@ -0,0 +1,134 @@ +## WebSocket server for [Pathfinder](https://github.com/exodus4d/pathfinder) + +### Requirements +- _PHP_ (≥ v7.1) +- A working instance of *[Pathfinder](https://github.com/exodus4d/pathfinder)* (≥ v2.0.0-rc.1) +- [_Composer_](https://getcomposer.org/download/) to install packages for the WebSocket server + +### Install +1. Checkout this project in a **new** folder e.g. `/var/www/websocket.pathfinder` +1. Install [_Composer_](https://getcomposer.org/download/) +2. Install Composer dependencies from `composer.json` file: + - `$ cd /var/www/websocket.pathfinder` + - `$ composer install` +3. Start WebSocket server `$ php cmd.php` + +### Configuration + +#### Default + +**Clients (WebBrowser) listen for connections** +- Host: `0.0.0.0.` (=> any client can connect) +- Port: `8020` +- ↪ URI: `127.0.0.1:8020` + +(=> Your WebServer (e.g. Nginx) should proxy all WebSocket connections to this source) + +**TCP TcpSocket connection (Internal use for WebServer ⇄ WebSocket server communication)** +- Host: `127.0.0.1` (=> Assumed WebServer and WebSocket server running on the same machine) +- Port: `5555` +- ↪ URI: `tcp://127.0.0.1:5555` + +(=> Where _Pathfinder_ reaches the WebSocket server. This must match `SOCKET_HOST`, `SOCKET_PORT` options in `environment.ini`) + +#### Start parameters [Optional] + +The default configuration should be fine for most installations. +You can change/overwrite the default **Host** and **Port** configuration by adding additional CLI parameters when starting the WebSocket server: + +`$ php cmd.php --wsHost [CLIENTS_HOST] --wsPort [CLIENTS_PORT] --tcpHost [TCP_HOST] --tcpPort [TCP_PORT] --debug 0` + + For example: If you want to change the the WebSocket port and increase debug output: + + `$ php cmd.php --wsPort 8030 --debug 3` + +##### --debug (default `--debug 2`) + +Allows you to set log output level from `0` (silent) - errors are not logged, to `3` (debug) for detailed logging. + +![alt text](https://i.imgur.com/KfNF4lk.png) + +### WebSocket UI + +There is a WebSocket section on _Pathinders_ `/setup` page. After the WebSocket server is started, you should check it if everything works. +You see the most recent WebSocket log entries, the current connection state, the current number of active connections and all maps that have subscriptions + +![alt text](https://i.imgur.com/dDUrnx2.png) + +Log entry view. Depending on the `--debug` parameter, the most recent (max 50) entries will be shown: + +![alt text](https://i.imgur.com/LIn9aNm.png) + +Subscriptions for each map: + +![alt text](https://i.imgur.com/fANYwho.gif) + +### Unix Service (systemd) + +#### New Service +It is recommended to wrap the `cmd.php` script in a Unix service, that over control the WebSocket server. +This creates a systemd service on CentOS7: +1. `$ cd /etc/systemd/system` +2. `$ vi websocket.pathfinder.service` +3. Copy script and adjust `ExecStart` and `WorkingDirectory` values: + +``` +[Unit] +Description = WebSocket server (Pathfinder) [LIVE] environment +After = multi-user.target + +[Service] +Type = idle +ExecStart = /usr/bin/php /var/www/websocket.pathfinder/pathfinder_websocket/cmd.php +WorkingDirectory = /var/www/websocket.pathfinder/pathfinder_websocket +TimeoutStopSec = 0 +Restart = always +LimitNOFILE = 10000 +Nice = 10 + +[Install] +WantedBy = multi-user.target +``` + +Now you can use the service to start/stop/restart your WebSocket server +- `$ systemctl start websocket.pathfinder.service` +- `$ systemctl restart websocket.pathfinder.service` +- `$ systemctl stop websocket.pathfinder.service` + +#### Auto-Restart the Service +You can automatically restart your service (e.g. on _EVE-Online_ downtime). Create a new "timer" for the automatic restart. +1. `$ cd /etc/systemd/system` (same dir as before) +2. `$ vi restart.websocket.pathfinder.timer` +3. Copy script: + +``` +[Unit] +Description = Restart timer (EVE downtime) for WebSocket server [LIVE] + +[Timer] +OnCalendar = *-*-* 12:01:00 +Persistent = true + +[Install] +WantedBy = timer.target +``` +Now we need a new "restart service" for the timer: +1. `$ cd /etc/systemd/system` (same dir as before) +2. `$ vi restart.websocket.pathfinder.service` +3. Copy script: + +``` +[Unit] +Description = Restart (periodically) WebSocket server [LIVE] + +[Service] +Type = oneshot +ExecStart = /usr/bin/systemctl try-restart websocket.pathfinder.service +``` +And then, we need to either restart the machine or launch +``` +systemctl start restart.websocket.pathfinder.timer +``` +### Info +- [*Ratchet*](http://socketo.me) - "WebSockets for PHP" +- [*ReactPHP*](https://reactphp.org) - "Event-driven, non-blocking I/O with PHP" diff --git a/websocket/app/Component/AbstractMessageComponent.php b/websocket/app/Component/AbstractMessageComponent.php new file mode 100644 index 0000000..299fb4b --- /dev/null +++ b/websocket/app/Component/AbstractMessageComponent.php @@ -0,0 +1,269 @@ + should be overwritten in child instances + * -> is used as "log store" name + */ + const COMPONENT_NAME = 'default'; + + /** + * log message server start + */ + const LOG_TEXT_SERVER_START = 'start WebSocket server…'; + + /** + * store for logs + * @var Store + */ + protected $logStore; + + /** + * stores all active connections + * -> regardless of its subscription state + * [ + * '$conn1->resourceId' => [ + * 'connection' => $conn1, + * 'data' => null + * ], + * '$conn2->resourceId' => [ + * 'connection' => $conn2, + * 'data' => null + * ] + * ] + * @var array + */ + private $connections; + + /** + * max count of concurrent open connections + * @var int + */ + private $maxConnections = 0; + + /** + * AbstractMessageComponent constructor. + * @param Store $store + */ + public function __construct(Store $store){ + $this->connections = []; + $this->logStore = $store; + + $this->log(['debug', 'info'], null, 'START', static::LOG_TEXT_SERVER_START); + } + + // Connection callbacks from MessageComponentInterface ============================================================ + + /** + * new client connection onOpen + * @param ConnectionInterface $conn + */ + public function onOpen(ConnectionInterface $conn){ + $this->log(['debug'], $conn, __FUNCTION__, 'open connection'); + + $this->addConnection($conn); + } + + /** + * client connection onClose + * @param ConnectionInterface $conn + */ + public function onClose(ConnectionInterface $conn){ + $this->log(['debug'], $conn, __FUNCTION__, 'close connection'); + + $this->removeConnection($conn); + } + + /** + * client connection onError + * @param ConnectionInterface $conn + * @param \Exception $e + */ + public function onError(ConnectionInterface $conn, \Exception $e){ + $this->log(['debug', 'error'], $conn, __FUNCTION__, $e->getMessage()); + } + + /** + * new message received from client connection + * @param ConnectionInterface $conn + * @param string $msg + */ + public function onMessage(ConnectionInterface $conn, $msg){ + // parse message into payload object + $payload = $this->getPayloadFromMessage($msg); + + if($payload){ + $this->dispatchWebSocketPayload($conn, $payload); + } + } + + // Connection handling ============================================================================================ + + /** + * add connection + * @param ConnectionInterface $conn + */ + private function addConnection(ConnectionInterface $conn) : void { + $this->connections[$conn->resourceId] = [ + 'connection' => $conn, + ]; + + $this->maxConnections = max(count($this->connections), $this->maxConnections); + } + + /** + * remove connection + * @param ConnectionInterface $conn + */ + private function removeConnection(ConnectionInterface $conn) : void { + if($this->hasConnection($conn)){ + unset($this->connections[$conn->resourceId]); + } + } + + /** + * @param ConnectionInterface $conn + * @return bool + */ + protected function hasConnection(ConnectionInterface $conn) : bool { + return isset($this->connections[$conn->resourceId]); + } + + /** + * @param int $resourceId + * @return bool + */ + protected function hasConnectionId(int $resourceId) : bool { + return isset($this->connections[$resourceId]); + } + + /** + * @param int $resourceId + * @return ConnectionInterface|null + */ + protected function getConnection(int $resourceId) : ?ConnectionInterface { + return $this->hasConnectionId($resourceId) ? $this->connections[$resourceId]['connection'] : null; + } + + /** + * update meta data for $conn + * @param ConnectionInterface $conn + */ + protected function updateConnection(ConnectionInterface $conn){ + if($this->hasConnection($conn)){ + $meta = [ + 'mTimeSend' => microtime(true) + ]; + $this->connections[$conn->resourceId]['data'] = array_merge($this->getConnectionData($conn), $meta); + } + } + + /** + * get meta data from $conn + * @param ConnectionInterface $conn + * @return array + */ + protected function getConnectionData(ConnectionInterface $conn) : array { + $meta = []; + if($this->hasConnection($conn)){ + $meta = (array)$this->connections[$conn->resourceId]['data']; + } + return $meta; + } + + /** + * wrapper for ConnectionInterface->send() + * -> this stores some meta data to the $conn + * @param ConnectionInterface $conn + * @param $data + */ + protected function send(ConnectionInterface $conn, $data){ + $conn->send($data); + $this->updateConnection($conn); + } + + /** + * @param ConnectionInterface $conn + * @param Payload $payload + */ + abstract protected function dispatchWebSocketPayload(ConnectionInterface $conn, Payload $payload) : void; + + /** + * get Payload class from client message + * @param mixed $msg + * @return Payload|null + */ + protected function getPayloadFromMessage($msg) : ?Payload { + $payload = null; + $msg = (array)json_decode($msg, true); + + if(isset($msg['task'], $msg['load'])){ + $payload = $this->newPayload((string)$msg['task'], $msg['load']); + } + + return $payload; + } + + /** + * @param string $task + * @param null $load + * @param array|null $characterIds + * @return Payload|null + */ + protected function newPayload(string $task, $load = null, ?array $characterIds = null) : ?Payload { + $payload = null; + try{ + $payload = new Payload($task, $load, $characterIds); + }catch(\Exception $e){ + $this->log(['debug', 'error'], null, __FUNCTION__, $e->getMessage()); + } + + return $payload; + } + + /** + * get WebSocket stats data + * @return array + */ + public function getSocketStats() : array { + return [ + 'connections' => count($this->connections), + 'maxConnections' => $this->maxConnections, + 'logs' => array_reverse($this->logStore->getStore()) + ]; + } + + /** + * @param $logTypes + * @param ConnectionInterface|null $connection + * @param string $action + * @param string $message + */ + protected function log($logTypes, ?ConnectionInterface $connection, string $action, string $message = '') : void { + if($this->logStore){ + $remoteAddress = $connection ? $connection->remoteAddress : null; + $resourceId = $connection ? $connection->resourceId : null; + $this->logStore->log($logTypes, $remoteAddress, $resourceId, $action, $message); + } + } + + /** + * + * @param TimerInterface $timer + */ + public function housekeeping(TimerInterface $timer) : void { + + } +} \ No newline at end of file diff --git a/websocket/app/Component/Formatter/SubscriptionFormatter.php b/websocket/app/Component/Formatter/SubscriptionFormatter.php new file mode 100644 index 0000000..f1e5972 --- /dev/null +++ b/websocket/app/Component/Formatter/SubscriptionFormatter.php @@ -0,0 +1,41 @@ + $characterData){ + // check if characterData has an active log (active system for character) + $systemId = 0; + if(isset($characterData['log']['system']['id'])){ + $systemId = (int)$characterData['log']['system']['id']; + } + + if( !isset($data[$systemId]) ){ + $systemData = (object)[]; + $systemData->id = $systemId; + $data[$systemId] = $systemData; + } + + $data[$systemId]->user[] = $characterData; + } + $data = array_values($data); + + return $data; + } + +} \ No newline at end of file diff --git a/websocket/app/Component/Handler/LogFileHandler.php b/websocket/app/Component/Handler/LogFileHandler.php new file mode 100644 index 0000000..a47ff7b --- /dev/null +++ b/websocket/app/Component/Handler/LogFileHandler.php @@ -0,0 +1,76 @@ +stream = $stream; + $this->dir = dirname($this->stream); + $this->createDir(); + } + + /** + * write log data into to file + * @param array $log + */ + public function write(array $log){ + $log = (string)json_encode($log, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if( !empty($log) ){ + if($stream = fopen($this->stream, 'a')){ + flock($stream, LOCK_EX); + fwrite($stream, $log . PHP_EOL); + flock($stream, LOCK_UN); + fclose($stream); + + // logs should be writable for non webSocket user too + @chmod($this->stream, 0666); + } + } + } + + /** + * create directory + */ + private function createDir(){ + // Do not try to create dir if it has already been tried. + if ($this->dirCreated){ + return; + } + + if ($this->dir && !is_dir($this->dir)){ + $status = mkdir($this->dir, 0777, true); + if (false === $status) { + throw new \UnexpectedValueException(sprintf(self::ERROR_DIR_CREATE, $this->dir)); + } + } + $this->dirCreated = true; + } +} \ No newline at end of file diff --git a/websocket/app/Component/MapUpdate.php b/websocket/app/Component/MapUpdate.php new file mode 100644 index 0000000..bc622ab --- /dev/null +++ b/websocket/app/Component/MapUpdate.php @@ -0,0 +1,943 @@ + should be overwritten in child instances + * -> is used as "log store" name + */ + const COMPONENT_NAME = 'webSock'; + + /** + * log message unknown task name + */ + const LOG_TEXT_TASK_UNKNOWN = 'unknown task: %s'; + + /** + * log message for denied subscription attempt. -> character data unknown + */ + const LOG_TEXT_SUBSCRIBE_DENY = 'sub. denied for charId: %d'; + + /** + * log message for invalid subscription data + */ + const LOG_TEXT_SUBSCRIBE_INVALID = 'sub. data invalid'; + + /** + * log message for subscribe characterId + */ + const LOG_TEXT_SUBSCRIBE = 'sub. charId: %s to mapIds: [%s]'; + + /** + * log message unsubscribe characterId + */ + const LOG_TEXT_UNSUBSCRIBE = 'unsub. charId: %d from mapIds: [%s]'; + + /** + * log message for map data updated broadcast + */ + const LOG_TEXT_MAP_UPDATE = 'update map data, mapId: %d → broadcast to %d connections'; + + /** + * log message for map subscriptions data updated broadcast + */ + const LOG_TEXT_MAP_SUBSCRIPTIONS = 'update map subscriptions data, mapId: %d. → broadcast to %d connections'; + + /** + * log message for delete mapId broadcast + */ + const LOG_TEXT_MAP_DELETE = 'delete mapId: $d → broadcast to %d connections'; + + /** + * timestamp (ms) from last healthCheck ping + * -> timestamp received from remote TCP socket + * @var int|null + */ + protected $healthCheckToken; + + /** + * expire time for map access tokens (seconds) + * @var int + */ + protected $mapAccessExpireSeconds = 30; + + /** + * character access tokens for clients + * -> tokens are unique and expire onSubscribe! + * [ + * 'charId_1' => [ + * [ + * 'token' => $characterToken1, + * 'expire' => $expireTime1, + * 'characterData' => $characterData1 + * ], + * [ + * 'token' => $characterToken2, + * 'expire' => $expireTime2, + * 'characterData' => $characterData1 + * ] + * ], + * 'charId_2' => [ + * [ + * 'token' => $characterToken3, + * 'expire' => $expireTime3, + * 'characterData' => $characterData2 + * ] + * ] + * ] + * @var array + */ + protected $characterAccessData; + + /** + * access tokens for clients grouped by mapId + * -> tokens are unique and expire onSubscribe! + * @var array + */ + protected $mapAccessData; + + /** + * connected characters + * [ + * 'charId_1' => [ + * '$conn1->resourceId' => $conn1, + * '$conn2->resourceId' => $conn2 + * ], + * 'charId_2' => [ + * '$conn1->resourceId' => $conn1, + * '$conn3->resourceId' => $conn3 + * ] + * ] + * @var array + */ + protected $characters; + + /** + * valid client connections subscribed to maps + * [ + * 'mapId_1' => [ + * 'charId_1' => $charId_1, + * 'charId_2' => $charId_2 + * ], + * 'mapId_2' => [ + * 'charId_1' => $charId_1, + * 'charId_3' => $charId_3 + * ] + * ] + * + * @var array + */ + protected $subscriptions; + + /** + * collection of characterData for valid subscriptions + * [ + * 'charId_1' => $characterData1, + * 'charId_2' => $characterData2 + * ] + * + * @var array + */ + protected $characterData; + + /** + * MapUpdate constructor. + * @param Store $store + */ + public function __construct(Store $store){ + parent::__construct($store); + + $this->characterAccessData = []; + $this->mapAccessData = []; + $this->characters = []; + $this->subscriptions = []; + $this->characterData = []; + } + + /** + * new client connection + * @param ConnectionInterface $conn + */ + public function onOpen(ConnectionInterface $conn){ + parent::onOpen($conn); + } + + /** + * @param ConnectionInterface $conn + */ + public function onClose(ConnectionInterface $conn){ + parent::onClose($conn); + + $this->unSubscribeConnection($conn); + } + + /** + * @param ConnectionInterface $conn + * @param \Exception $e + */ + public function onError(ConnectionInterface $conn, \Exception $e){ + parent::onError($conn, $e); + + // close connection should trigger the onClose() callback for unSubscribe + $conn->close(); + } + + /** + * @param ConnectionInterface $conn + * @param string $msg + */ + public function onMessage(ConnectionInterface $conn, $msg){ + parent::onMessage($conn, $msg); + } + + /** + * @param ConnectionInterface $conn + * @param Payload $payload + */ + protected function dispatchWebSocketPayload(ConnectionInterface $conn, Payload $payload) : void { + switch($payload->task){ + case 'healthCheck': + $this->broadcastHealthCheck($conn, $payload); + break; + case 'subscribe': + $this->subscribe($conn, (array)$payload->load); + break; + case 'unsubscribe': + // make sure characterIds got from client are valid + // -> intersect with subscribed characterIds for current $conn + $characterIds = array_intersect((array)$payload->load, $this->getCharacterIdsByConnection($conn)); + if(!empty($characterIds)){ + $this->unSubscribeCharacterIds($characterIds, $conn); + } + break; + default: + $this->log(['debug', 'error'], $conn, __FUNCTION__, sprintf(static::LOG_TEXT_TASK_UNKNOWN, $payload->task)); + break; + } + } + + /** + * checks healthCheck $token and respond with validation status + subscription stats + * @param ConnectionInterface $conn + * @param Payload $payload + */ + private function broadcastHealthCheck(ConnectionInterface $conn, Payload $payload) : void { + $isValid = $this->validateHealthCheckToken((int)$payload->load); + + $load = [ + 'isValid' => $isValid, + ]; + + // Make sure WebSocket client request is valid + if($isValid){ + // set new healthCheckToken for next check + $load['token'] = $this->setHealthCheckToken(microtime(true)); + + // add subscription stats if $token is valid + $load['subStats'] = $this->getSubscriptionStats(); + } + + $payload->setLoad($load); + + $connections = new \SplObjectStorage(); + $connections->attach($conn); + + $this->broadcast($connections, $payload); + + } + + /** + * compare token (timestamp from initial TCP healthCheck message) with token send from WebSocket + * @param int $token + * @return bool + */ + private function validateHealthCheckToken(int $token) : bool { + $isValid = false; + + if($token && $this->healthCheckToken && $token === (int)$this->healthCheckToken){ + $isValid = true; + } + + // reset token + $this->healthCheckToken = null; + + return $isValid; + } + + /** + * subscribes a connection to valid accessible maps + * @param ConnectionInterface $conn + * @param $subscribeData + */ + private function subscribe(ConnectionInterface $conn, array $subscribeData) : void { + $characterId = (int)$subscribeData['id']; + $characterToken = (string)$subscribeData['token']; + + if($characterId && $characterToken){ + // check if character access token is valid (exists and not expired in $this->characterAccessData) + if($characterData = $this->checkCharacterAccess($characterId, $characterToken)){ + $this->characters[$characterId][$conn->resourceId] = $conn; + + // insert/update characterData cache + // -> even if characterId does not have access to a map "yet" + // -> no maps found but character can get map access at any time later + $this->setCharacterData($characterData); + + // valid character -> check map access + $changedSubscriptionsMapIds = []; + foreach((array)$subscribeData['mapData'] as $data){ + $mapId = (int)$data['id']; + $mapToken = (string)$data['token']; + $mapName = (string)$data['name']; + + if($mapId && $mapToken){ + // check if token is valid (exists and not expired) in $this->mapAccessData + if($this->checkMapAccess($characterId, $mapId, $mapToken)){ + // valid map subscribe request + $this->subscriptions[$mapId]['characterIds'][$characterId] = $characterId; + $this->subscriptions[$mapId]['data']['name'] = $mapName; + $changedSubscriptionsMapIds[] = $mapId; + } + } + } + + sort($changedSubscriptionsMapIds, SORT_NUMERIC); + + $this->log(['debug', 'info'], $conn, __FUNCTION__, + sprintf(static::LOG_TEXT_SUBSCRIBE, $characterId, implode(',', $changedSubscriptionsMapIds)) + ); + + // broadcast all active subscriptions to subscribed connections ------------------------------------------- + $this->broadcastMapSubscriptions($changedSubscriptionsMapIds); + }else{ + $this->log(['debug', 'info'], $conn, __FUNCTION__, sprintf(static::LOG_TEXT_SUBSCRIBE_DENY, $characterId)); + } + }else{ + $this->log(['debug', 'error'], $conn, __FUNCTION__, static::LOG_TEXT_SUBSCRIBE_INVALID); + } + } + + /** + * subscribes an active connection from maps + * @param ConnectionInterface $conn + */ + private function unSubscribeConnection(ConnectionInterface $conn){ + $characterIds = $this->getCharacterIdsByConnection($conn); + $this->unSubscribeCharacterIds($characterIds, $conn); + } + + /** + * unSubscribe a $characterId from ALL maps + * -> if $conn is set -> just unSub the $characterId from this $conn + * @param int $characterId + * @param ConnectionInterface|null $conn + * @return bool + */ + private function unSubscribeCharacterId(int $characterId, ?ConnectionInterface $conn = null) : bool { + if($characterId){ + // unSub from $this->characters --------------------------------------------------------------------------- + if($conn){ + // just unSub a specific connection (e.g. single browser window) + unset($this->characters[$characterId][$conn->resourceId]); + + if( !count($this->characters[$characterId]) ){ + // no connection left for this character + unset($this->characters[$characterId]); + } + // TODO unset $this->>$characterData if $characterId does not have any other map subscribed to + }else{ + // unSub ALL connections from a character (e.g. multiple browsers) + unset($this->characters[$characterId]); + + // unset characterData cache + $this->deleteCharacterData($characterId); + } + + // unSub from $this->subscriptions ------------------------------------------------------------------------ + $changedSubscriptionsMapIds = []; + foreach($this->subscriptions as $mapId => $subData){ + if(array_key_exists($characterId, (array)$subData['characterIds'])){ + unset($this->subscriptions[$mapId]['characterIds'][$characterId]); + + if( !count($this->subscriptions[$mapId]['characterIds']) ){ + // no characters left on this map + unset($this->subscriptions[$mapId]); + } + + $changedSubscriptionsMapIds[] = $mapId; + } + } + + sort($changedSubscriptionsMapIds, SORT_NUMERIC); + + $this->log(['debug', 'info'], $conn, __FUNCTION__, + sprintf(static::LOG_TEXT_UNSUBSCRIBE, $characterId, implode(',', $changedSubscriptionsMapIds)) + ); + + // broadcast all active subscriptions to subscribed connections ------------------------------------------- + $this->broadcastMapSubscriptions($changedSubscriptionsMapIds); + } + + return true; + } + + /** + * unSubscribe $characterIds from ALL maps + * -> if $conn is set -> just unSub the $characterId from this $conn + * @param int[] $characterIds + * @param ConnectionInterface|null $conn + * @return bool + */ + private function unSubscribeCharacterIds(array $characterIds, ?ConnectionInterface $conn = null) : bool { + $response = false; + foreach($characterIds as $characterId){ + $response = $this->unSubscribeCharacterId($characterId, $conn); + } + return $response; + } + + /** + * delete mapId from subscriptions and broadcast "delete msg" to clients + * @param string $task + * @param int $mapId + * @return int + */ + private function deleteMapId(string $task, int $mapId) : int { + $connectionCount = $this->broadcastMapData($task, $mapId, $mapId); + + // remove map from subscriptions + if(isset($this->subscriptions[$mapId])){ + unset($this->subscriptions[$mapId]); + } + + $this->log(['debug', 'info'], null, __FUNCTION__, + sprintf(static::LOG_TEXT_MAP_DELETE, $mapId, $connectionCount) + ); + + return $connectionCount; + } + + /** + * get all mapIds a characterId has subscribed to + * @param int $characterId + * @return int[] + */ + private function getMapIdsByCharacterId(int $characterId) : array { + $mapIds = []; + foreach($this->subscriptions as $mapId => $subData) { + if(array_key_exists($characterId, (array)$subData['characterIds'])){ + $mapIds[] = $mapId; + } + } + return $mapIds; + } + + /** + * @param ConnectionInterface $conn + * @return int[] + */ + private function getCharacterIdsByConnection(ConnectionInterface $conn) : array { + $characterIds = []; + $resourceId = $conn->resourceId; + + foreach($this->characters as $characterId => $resourceIDs){ + if( + array_key_exists($resourceId, $resourceIDs) && + !in_array($characterId, $characterIds) + ){ + $characterIds[] = $characterId; + } + } + return $characterIds; + } + + /** + * @param $mapId + * @return array + */ + private function getCharacterIdsByMapId(int $mapId) : array { + $characterIds = []; + if( + array_key_exists($mapId, $this->subscriptions) && + is_array($this->subscriptions[$mapId]['characterIds']) + ){ + $characterIds = array_keys($this->subscriptions[$mapId]['characterIds']); + } + return $characterIds; + } + + /** + * get connections by $characterIds + * @param int[] $characterIds + * @return \SplObjectStorage + */ + private function getConnectionsByCharacterIds(array $characterIds) : \SplObjectStorage { + $connections = new \SplObjectStorage; + foreach($characterIds as $characterId){ + $connections->addAll($this->getConnectionsByCharacterId($characterId)); + } + return $connections; + } + + /** + * get connections by $characterId + * @param int $characterId + * @return \SplObjectStorage + */ + private function getConnectionsByCharacterId(int $characterId) : \SplObjectStorage { + $connections = new \SplObjectStorage; + if(isset($this->characters[$characterId])){ + foreach(array_keys($this->characters[$characterId]) as $resourceId){ + if( + $this->hasConnectionId($resourceId) && + !$connections->contains($conn = $this->getConnection($resourceId)) + ){ + $connections->attach($conn); + } + } + } + return $connections; + } + + /** + * check character access against $this->characterAccessData whitelist + * @param $characterId + * @param $characterToken + * @return array + */ + private function checkCharacterAccess(int $characterId, string $characterToken) : array { + $characterData = []; + if( !empty($characterAccessData = (array)$this->characterAccessData[$characterId]) ){ + // check expire for $this->characterAccessData -> check ALL characters and remove expired + foreach($characterAccessData as $i => $data){ + $deleteToken = false; + + if( ((int)$data['expire'] - time()) > 0 ){ + // still valid -> check token + if($characterToken === $data['token']){ + $characterData = $data['characterData']; + $deleteToken = true; + // NO break; here -> check other characterAccessData as well + } + }else{ + // token expired + $deleteToken = true; + } + + if($deleteToken){ + unset($this->characterAccessData[$characterId][$i]); + // -> check if tokens for this charId is empty + if( empty($this->characterAccessData[$characterId]) ){ + unset($this->characterAccessData[$characterId]); + + } + } + } + } + + return $characterData; + } + + /** + * check map access against $this->mapAccessData whitelist + * @param $characterId + * @param $mapId + * @param $mapToken + * @return bool + */ + private function checkMapAccess(int $characterId, int $mapId, string $mapToken) : bool { + $access = false; + if( !empty($mapAccessData = (array)$this->mapAccessData[$mapId][$characterId]) ){ + foreach($mapAccessData as $i => $data){ + $deleteToken = false; + // check expire for $this->mapAccessData -> check ALL characters and remove expired + if( ((int)$data['expire'] - time()) > 0 ){ + // still valid -> check token + if($mapToken === $data['token']){ + $access = true; + $deleteToken = true; + } + }else{ + // token expired + $deleteToken = true; + } + + if($deleteToken){ + unset($this->mapAccessData[$mapId][$characterId][$i]); + // -> check if tokens for this charId is empty + if( empty($this->mapAccessData[$mapId][$characterId]) ){ + unset($this->mapAccessData[$mapId][$characterId]); + // -> check if map has no access tokens left for characters + if( empty($this->mapAccessData[$mapId]) ){ + unset($this->mapAccessData[$mapId]); + } + } + } + } + } + return $access; + } + + /** + * broadcast $payload to $connections + * @param \SplObjectStorage $connections + * @param Payload $payload + */ + private function broadcast(\SplObjectStorage $connections, Payload $payload) : void { + $data = json_encode($payload); + foreach($connections as $conn){ + $this->send($conn, $data); + } + } + + // custom calls =================================================================================================== + + /** + * receive data from TCP socket (main App) + * -> send response back + * @param string $task + * @param null|int|array $load + * @return bool|float|int|null + */ + public function receiveData(string $task, $load = null){ + $responseLoad = null; + + switch($task){ + case 'healthCheck': + $responseLoad = $this->setHealthCheckToken((float)$load); + break; + case 'characterUpdate': + $this->updateCharacterData((array)$load); + $mapIds = $this->getMapIdsByCharacterId((int)$load['id']); + $this->broadcastMapSubscriptions($mapIds); + break; + case 'characterLogout': + $responseLoad = $this->unSubscribeCharacterIds((array)$load); + break; + case 'mapConnectionAccess': + $responseLoad = $this->setConnectionAccess($load); + break; + case 'mapAccess': + $responseLoad = $this->setAccess($task, $load); + break; + case 'mapUpdate': + $responseLoad = $this->broadcastMapUpdate($task, (array)$load); + break; + case 'mapDeleted': + $responseLoad = $this->deleteMapId($task, (int)$load); + break; + case 'logData': + $this->handleLogData((array)$load['meta'], (array)$load['log']); + break; + } + + return $responseLoad; + } + + /** + * @param float $token + * @return float + */ + private function setHealthCheckToken(float $token) : float { + $this->healthCheckToken = $token; + return $this->healthCheckToken; + } + + /** + * @param array $characterData + */ + private function setCharacterData(array $characterData) : void { + if($characterId = (int)$characterData['id']){ + $this->characterData[$characterId] = $characterData; + } + } + + /** + * @param int $characterId + * @return array + */ + private function getCharacterData(int $characterId) : array { + return empty($this->characterData[$characterId]) ? [] : $this->characterData[$characterId]; + } + + /** + * @param array $characterIds + * @return array + */ + private function getCharactersData(array $characterIds) : array { + return array_filter($this->characterData, function($characterId) use($characterIds) { + return in_array($characterId, $characterIds); + }, ARRAY_FILTER_USE_KEY); + } + + /** + * @param array $characterData + */ + private function updateCharacterData(array $characterData) : void { + $characterId = (int)$characterData['id']; + if($this->getCharacterData($characterId)){ + $this->setCharacterData($characterData); + } + } + + /** + * @param int $characterId + */ + private function deleteCharacterData(int $characterId) : void { + unset($this->characterData[$characterId]); + } + + /** + * @param array $mapIds + */ + private function broadcastMapSubscriptions(array $mapIds) : void { + $mapIds = array_unique($mapIds); + + foreach($mapIds as $mapId){ + if( + !empty($characterIds = $this->getCharacterIdsByMapId($mapId)) && + !empty($charactersData = $this->getCharactersData($characterIds)) + ){ + $systems = SubscriptionFormatter::groupCharactersDataBySystem($charactersData); + + $mapUserData = (object)[]; + $mapUserData->config = (object)['id' => $mapId]; + $mapUserData->data = (object)['systems' => $systems]; + + $connectionCount = $this->broadcastMapData('mapSubscriptions', $mapId, $mapUserData); + + $this->log(['debug'], null, __FUNCTION__, + sprintf(static::LOG_TEXT_MAP_SUBSCRIPTIONS, $mapId, $connectionCount) + ); + } + } + } + + /** + * @param string $task + * @param array $mapData + * @return int + */ + private function broadcastMapUpdate(string $task, array $mapData) : int { + $mapId = (int)$mapData['config']['id']; + $connectionCount = $this->broadcastMapData($task, $mapId, $mapData); + + $this->log(['debug'], null, __FUNCTION__, + sprintf(static::LOG_TEXT_MAP_UPDATE, $mapId, $connectionCount) + ); + + return $connectionCount; + } + + /** + * send map data to ALL connected clients + * @param string $task + * @param int $mapId + * @param mixed $load + * @return int + */ + private function broadcastMapData(string $task, int $mapId, $load) : int { + $characterIds = $this->getCharacterIdsByMapId($mapId); + $connections = $this->getConnectionsByCharacterIds($characterIds); + + $this->broadcast($connections, $this->newPayload($task, $load, $characterIds)); + + return count($connections); + } + + /** + * set/update map access for allowed characterIds + * @param string $task + * @param array $accessData + * @return int count of connected characters + */ + private function setAccess(string $task, $accessData) : int { + $newMapCharacterIds = []; + + if($mapId = (int)$accessData['id']){ + $mapName = (string)$accessData['name']; + $characterIds = (array)$accessData['characterIds']; + // check all charactersIds that have map access... -------------------------------------------------------- + foreach($characterIds as $characterId){ + // ... for at least ONE active connection ... + // ... and characterData cache exists for characterId + if( + !empty($this->characters[$characterId]) && + !empty($this->getCharacterData($characterId)) + ){ + $newMapCharacterIds[$characterId] = $characterId; + } + } + + $currentMapCharacterIds = (array)$this->subscriptions[$mapId]['characterIds']; + + // broadcast "map delete" to no longer valid characters --------------------------------------------------- + $removedMapCharacterIds = array_keys(array_diff_key($currentMapCharacterIds, $newMapCharacterIds)); + $removedMapCharacterConnections = $this->getConnectionsByCharacterIds($removedMapCharacterIds); + + $this->broadcast($removedMapCharacterConnections, $this->newPayload($task, $mapId, $removedMapCharacterIds)); + + // update map subscriptions ------------------------------------------------------------------------------- + if( !empty($newMapCharacterIds) ){ + // set new characters that have map access (overwrites existing subscriptions for that map) + $this->subscriptions[$mapId]['characterIds'] = $newMapCharacterIds; + $this->subscriptions[$mapId]['data']['name'] = $mapName; + + // check if subscriptions have changed + if( !$this->arraysEqualKeys($currentMapCharacterIds, $newMapCharacterIds) ){ + $this->broadcastMapSubscriptions([$mapId]); + } + }else{ + // no characters (left) on this map + unset($this->subscriptions[$mapId]); + } + } + return count($newMapCharacterIds); + } + + /** + * set map access data (whitelist) tokens for map access + * @param $connectionAccessData + * @return bool + */ + private function setConnectionAccess($connectionAccessData){ + $response = false; + $characterId = (int)$connectionAccessData['id']; + $characterData = $connectionAccessData['characterData']; + $characterToken = $connectionAccessData['token']; + + if( + $characterId && + $characterData && + $characterToken + ){ + // expire time for character and map tokens + $expireTime = time() + $this->mapAccessExpireSeconds; + + // tokens for character access + $this->characterAccessData[$characterId][] = [ + 'token' => $characterToken, + 'expire' => $expireTime, + 'characterData' => $characterData + ]; + + foreach((array)$connectionAccessData['mapData'] as $mapData){ + $mapId = (int)$mapData['id']; + + $this->mapAccessData[$mapId][$characterId][] = [ + 'token' => $mapData['token'], + 'expire' => $expireTime + ]; + } + + $response = 'OK'; + } + + return $response; + } + + /** + * get stats data + * -> lists all channels, subscribed characters + connection info + * @return array + */ + protected function getSubscriptionStats() : array { + $uniqueConnections = []; + $uniqueSubscriptions = []; + $channelsStats = []; + + foreach($this->subscriptions as $mapId => $subData){ + $characterIds = $this->getCharacterIdsByMapId($mapId); + $uniqueMapConnections = []; + + $channelStats = [ + 'channelId' => $mapId, + 'channelName' => $subData['data']['name'], + 'countSub' => count($characterIds), + 'countCon' => 0, + 'subscriptions' => [] + ]; + + foreach($characterIds as $characterId){ + $characterData = $this->getCharacterData($characterId); + $connections = $this->getConnectionsByCharacterId($characterId); + + $characterStats = [ + 'characterId' => $characterId, + 'characterName' => isset($characterData['name']) ? $characterData['name'] : null, + 'countCon' => $connections->count(), + 'connections' => [] + ]; + + foreach($connections as $connection){ + if(!in_array($connection->resourceId, $uniqueMapConnections)){ + $uniqueMapConnections[] = $connection->resourceId; + } + + $metaData = $this->getConnectionData($connection); + $microTime = (float)$metaData['mTimeSend']; + $logTime = Store::getDateTimeFromMicrotime($microTime); + + $characterStats['connections'][] = [ + 'resourceId' => $connection->resourceId, + 'remoteAddress' => $connection->remoteAddress, + 'mTimeSend' => $microTime, + 'mTimeSendFormat1' => $logTime->format('Y-m-d H:i:s.u'), + 'mTimeSendFormat2' => $logTime->format('H:i:s') + ]; + } + + $channelStats['subscriptions'][] = $characterStats; + } + + $uniqueConnections = array_unique(array_merge($uniqueConnections, $uniqueMapConnections)); + $uniqueSubscriptions = array_unique(array_merge($uniqueSubscriptions, $characterIds)); + + $channelStats['countCon'] = count($uniqueMapConnections); + + $channelsStats[] = $channelStats; + } + + return [ + 'countSub' => count($uniqueSubscriptions), + 'countCon' => count($uniqueConnections), + 'channels' => $channelsStats + ]; + } + + /** + * compare two assoc arrays by keys. Key order is ignored + * -> if all keys from array1 exist in array2 && all keys from array2 exist in array 1, arrays are supposed to be equal + * @param array $array1 + * @param array $array2 + * @return bool + */ + protected function arraysEqualKeys(array $array1, array $array2) : bool { + return !array_diff_key($array1, $array2) && !array_diff_key($array2, $array1); + } + + /** + * dispatch log writing to a LogFileHandler + * @param array $meta + * @param array $log + */ + private function handleLogData(array $meta, array $log){ + $logHandler = new LogFileHandler((string)$meta['stream']); + $logHandler->write($log); + } +} \ No newline at end of file diff --git a/websocket/app/Data/Payload.php b/websocket/app/Data/Payload.php new file mode 100644 index 0000000..5d72db3 --- /dev/null +++ b/websocket/app/Data/Payload.php @@ -0,0 +1,94 @@ + recipients + * -> e.g if multiple browser tabs are open + * @var null|array + */ + private $characterIds; + + /** + * Payload constructor. + * @param string $task + * @param null $load + * @param array|null $characterIds + */ + public function __construct(string $task, $load = null, ?array $characterIds = null){ + $this->setTask($task); + $this->setLoad($load); + $this->setCharacterIds($characterIds); + } + + /** + * @param string $task + */ + public function setTask(string $task){ + if($task){ + $this->task = $task; + }else{ + throw new \InvalidArgumentException(self::ERROR_TASK_MISSING); + } + } + + /** + * @param null $load + */ + public function setLoad($load = null){ + $this->load = $load; + } + + /** + * @param array|null $characterIds + */ + public function setCharacterIds(?array $characterIds){ + if(is_array($characterIds)){ + $this->characterIds = $characterIds; + }else{ + $this->characterIds = null; + } + } + + /** + * @param $name + * @return mixed + */ + public function __get($name){ + return $this->$name; + } + + /** + * @return array|mixed + */ + public function jsonSerialize(){ + return get_object_vars($this); + } +} \ No newline at end of file diff --git a/websocket/app/Log/ShellColors.php b/websocket/app/Log/ShellColors.php new file mode 100644 index 0000000..0858179 --- /dev/null +++ b/websocket/app/Log/ShellColors.php @@ -0,0 +1,90 @@ +foregroundColors['black'] = '0;30'; + $this->foregroundColors['dark_gray'] = '1;30'; + $this->foregroundColors['blue'] = '0;34'; + $this->foregroundColors['light_blue'] = '1;34'; + $this->foregroundColors['green'] = '0;32'; + $this->foregroundColors['light_green'] = '1;32'; + $this->foregroundColors['cyan'] = '0;36'; + $this->foregroundColors['light_cyan'] = '1;36'; + $this->foregroundColors['red'] = '0;31'; + $this->foregroundColors['light_red'] = '1;31'; + $this->foregroundColors['purple'] = '0;35'; + $this->foregroundColors['light_purple'] = '1;35'; + $this->foregroundColors['brown'] = '0;33'; + $this->foregroundColors['yellow'] = '1;33'; + $this->foregroundColors['light_gray'] = '0;37'; + $this->foregroundColors['white'] = '1;37'; + + $this->backgroundColors['black'] = '40'; + $this->backgroundColors['red'] = '41'; + $this->backgroundColors['green'] = '42'; + $this->backgroundColors['yellow'] = '43'; + $this->backgroundColors['blue'] = '44'; + $this->backgroundColors['magenta'] = '45'; + $this->backgroundColors['cyan'] = '46'; + $this->backgroundColors['light_gray'] = '47'; + } + + /** + * get colored string + * @param string $string + * @param string|null $foregroundColor + * @param string|null $backgroundColor + * @return string + */ + public function getColoredString(string $string, ?string $foregroundColor = null, ?string $backgroundColor = null) : string { + $coloredString = ""; + + // Check if given foreground color found + if (isset($this->foregroundColors[$foregroundColor])) { + $coloredString .= "\033[" . $this->foregroundColors[$foregroundColor] . "m"; + } + // Check if given background color found + if (isset($this->backgroundColors[$backgroundColor])) { + $coloredString .= "\033[" . $this->backgroundColors[$backgroundColor] . "m"; + } + + // Add string and end coloring + $coloredString .= $string . "\033[0m"; + + return $coloredString; + } + + /** + * returns all foreground color names + * @return array + */ + public function getForegroundColors() : array { + return array_keys($this->foregroundColors); + } + + /** + * returns all background color names + * @return array + */ + public function getBackgroundColors() : array { + return array_keys($this->backgroundColors); + } +} \ No newline at end of file diff --git a/websocket/app/Log/Store.php b/websocket/app/Log/Store.php new file mode 100644 index 0000000..28ffbf6 --- /dev/null +++ b/websocket/app/Log/Store.php @@ -0,0 +1,220 @@ + store size should be limited for memory reasons + * @var array + */ + private $store = []; + + /** + * all valid types for custom log events + * if value is false, logs for this type are ignored + * @var array + */ + protected $logTypes = [ + 'error' => true, + 'info' => true, + 'debug' => true + ]; + + /** + * if Store is locked, current state can not be changed + * @var bool + */ + protected $locked = false; + + /** + * @var ShellColors + */ + static $colors; + + /** + * Store constructor. + * @param string $name + */ + public function __construct(string $name){ + $this->name = $name; + } + + /** + * get all stored log entries + * @return array + */ + public function getStore() : array { + return $this->store; + } + + /** + * @param bool $locked + */ + public function setLocked(bool $locked){ + $this->locked = $locked; + } + + /** + * @return bool + */ + public function isLocked() : bool { + return $this->locked; + } + + /** + * @param int $logLevel + */ + public function setLogLevel(int $logLevel){ + switch($logLevel){ + case 3: + $this->logTypes['error'] = true; + $this->logTypes['info'] = true; + $this->logTypes['debug'] = true; + break; + case 2: + $this->logTypes['error'] = true; + $this->logTypes['info'] = true; + $this->logTypes['debug'] = false; + break; + case 1: + $this->logTypes['error'] = true; + $this->logTypes['info'] = false; + $this->logTypes['debug'] = false; + break; + case 0: + default: + $this->setLocked(true); // no logging + } + } + + /** + * this is used for custom log events like 'error', 'debug',... + * works as dispatcher method that calls individual log*() methods + * @param $logTypes + * @param string|null $remoteAddress + * @param int|null $resourceId + * @param string $action + * @param string $message + */ + public function log($logTypes, ?string $remoteAddress, ?int $resourceId, string $action, string $message = '') : void { + if(!$this->isLocked()){ + // filter out logTypes that should not be logged + $logTypes = array_filter((array)$logTypes, function(string $type) : bool { + return array_key_exists($type, $this->logTypes) && $this->logTypes[$type]; + }); + + if($logTypes){ + // get log entry data + $logData = $this->getLogData($logTypes, $remoteAddress, $resourceId, $action, $message); + + if(self::DEFAULT_LOG_TO_STDOUT){ + $this->echoLog($logData); + } + + // add entry to local store and check size limit for store + $this->store[] = $logData; + $this->store = array_slice($this->store, self::DEFAULT_LOG_STORE_SIZE * -1); + } + } + } + + /** + * get log data as array for a custom log entry + * @param array $logTypes + * @param string|null $remoteAddress + * @param int|null $resourceId + * @param string $action + * @param string $message + * @return array + */ + private function getLogData(array $logTypes, ?string $remoteAddress, ?int $resourceId, string $action, string $message = '') : array { + $file = null; + $lineNum = null; + $function = null; + + $traceIndex = 4; + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $traceIndex); + if(count($backtrace) == $traceIndex){ + $caller = $backtrace[$traceIndex - 2]; + $callerOrig = $backtrace[$traceIndex - 1]; + + $file = substr($caller['file'], strlen(dirname(dirname(dirname($caller['file'])))) + 1); + $lineNum = $caller['line']; + $function = $callerOrig['function']; + } + + $microTime = microtime(true); + $logTime = self::getDateTimeFromMicrotime($microTime); + + return [ + 'store' => $this->name, + 'mTime' => $microTime, + 'mTimeFormat1' => $logTime->format('Y-m-d H:i:s.u'), + 'mTimeFormat2' => $logTime->format('H:i:s'), + 'logTypes' => $logTypes, + 'remoteAddress' => $remoteAddress, + 'resourceId' => $resourceId, + 'fileName' => $file, + 'lineNumber' => $lineNum, + 'function' => $function, + 'action' => $action, + 'message' => $message + ]; + } + + /** + * echo log data to stdout -> terminal + * @param array $logData + */ + private function echoLog(array $logData) : void { + if(!self::$colors){ + self::$colors = new ShellColors(); + } + + $data = [ + self::$colors->getColoredString($logData['mTimeFormat1'], 'dark_gray'), + self::$colors->getColoredString($logData['store'], $logData['store'] == 'webSock' ? 'brown' : 'cyan'), + $logData['remoteAddress'] . ($logData['resourceId'] ? ' #' . $logData['resourceId'] : ''), + self::$colors->getColoredString($logData['fileName'] . ' line ' . $logData['lineNumber'], 'dark_gray'), + self::$colors->getColoredString($logData['function'] . '()' . (($logData['function'] !== $logData['action']) ? ' [' . $logData['action'] . ']' : ''), 'dark_gray'), + implode(',', (array)$logData['logTypes']), + self::$colors->getColoredString($logData['message'], 'light_purple') + ]; + + echo implode(' | ', array_filter($data)) . PHP_EOL; + } + + /** + * @see https://stackoverflow.com/a/29598719/4329969 + * @param float $mTime + * @return \DateTime + */ + public static function getDateTimeFromMicrotime(float $mTime) : \DateTime { + return \DateTime::createFromFormat('U.u', number_format($mTime, 6, '.', '')); + } +} \ No newline at end of file diff --git a/websocket/app/Socket/AbstractSocket.php b/websocket/app/Socket/AbstractSocket.php new file mode 100644 index 0000000..5753c03 --- /dev/null +++ b/websocket/app/Socket/AbstractSocket.php @@ -0,0 +1,67 @@ + should be overwritten in child instances + * -> is used as "log store" name + */ + const COMPONENT_NAME = 'default'; + + /** + * global server loop + * @var EventLoop\LoopInterface + */ + protected $loop; + + /** + * @var MessageComponentInterface + */ + protected $handler; + + /** + * @var Store + */ + protected $logStore; + + /** + * AbstractSocket constructor. + * @param EventLoop\LoopInterface $loop + * @param MessageComponentInterface $handler + * @param Store $store + */ + public function __construct( + EventLoop\LoopInterface $loop, + MessageComponentInterface $handler, + Store $store + ){ + $this->loop = $loop; + $this->handler = $handler; + $this->logStore = $store; + + $this->log(['debug', 'info'], null, 'START', 'start Socket server…'); + } + + /** + * @param $logTypes + * @param Socket\ConnectionInterface|null $connection + * @param string $action + * @param string $message + */ + public function log($logTypes, ?Socket\ConnectionInterface $connection, string $action, string $message = '') : void { + if(!$this->logStore->isLocked()){ + $remoteAddress = $connection ? $connection->getRemoteAddress() : null; + $this->logStore->log($logTypes, $remoteAddress, null, $action, $message); + } + } + +} \ No newline at end of file diff --git a/websocket/app/Socket/TcpSocket.php b/websocket/app/Socket/TcpSocket.php new file mode 100644 index 0000000..0af264c --- /dev/null +++ b/websocket/app/Socket/TcpSocket.php @@ -0,0 +1,551 @@ + should be overwritten in child instances + * -> is used as "log store" name + */ + const COMPONENT_NAME = 'tcpSock'; + + /** + * error message for unknown acceptType + * @see TcpSocket::DEFAULT_ACCEPT_TYPE + */ + const ERROR_ACCEPT_TYPE = "Unknown acceptType: '%s'"; + + /** + * error message for connected stream is not readable + */ + const ERROR_STREAM_NOT_READABLE = "Stream is not readable. Remote address: '%s'"; + + /** + * error message for connection stream is not writable + */ + const ERROR_STREAM_NOT_WRITABLE = "Stream is not writable. Remote address: '%s'"; + + /** + * error message for missing 'task' key in payload + */ + const ERROR_TASK_MISSING = "Missing 'task' in payload"; + + /** + * error message for unknown 'task' key in payload + */ + const ERROR_TASK_UNKNOWN = "Unknown 'task': '%s' in payload"; + + /** + * error message for missing method + */ + const ERROR_METHOD_MISSING = "Method '%S' not found"; + + /** + * error message for waitTimeout exceeds + * @see TcpSocket::DEFAULT_WAIT_TIMEOUT + */ + const ERROR_WAIT_TIMEOUT = "Exceeds 'waitTimeout': %ss"; + + /** + * default for: accepted data type + * -> affects en/decoding socket data + */ + const DEFAULT_ACCEPT_TYPE = 'json'; + + /** + * default for: wait timeout + * -> timeout until connection gets closed + * timeout should be "reset" right after successful response send to client + */ + const DEFAULT_WAIT_TIMEOUT = 3.0; + + /** + * default for: send response by end() method (rather than write()) + * -> connection will get closed right after successful response send to client + */ + const DEFAULT_END_WITH_RESPONSE = true; + + /** + * default for: add socket statistic to response payload + */ + const DEFAULT_ADD_STATS = false; + + /** + * max length for JSON data string + * -> throw OverflowException on exceed + */ + const JSON_DECODE_MAX_LENGTH = 65536 * 4; + + /** + * @see TcpSocket::DEFAULT_ACCEPT_TYPE + * @var string + */ + private $acceptType = self::DEFAULT_ACCEPT_TYPE; + + /** + * @see TcpSocket::DEFAULT_WAIT_TIMEOUT + * @var float + */ + private $waitTimeout = self::DEFAULT_WAIT_TIMEOUT; + + /** + * @see TcpSocket::DEFAULT_END_WITH_RESPONSE + * @var bool + */ + private $endWithResponse = self::DEFAULT_END_WITH_RESPONSE; + + /** + * @see TcpSocket::DEFAULT_STATS + * @var bool + */ + private $addStats = self::DEFAULT_ADD_STATS; + + /** + * storage for all active connections + * -> can be used to get current count of connected clients + * @var \SplObjectStorage + */ + private $connections; + + /** + * max count of concurrent open connections + * -> represents number of active connected clients + * @var int + */ + private $maxConnections = 0; + + /** + * timestamp on startup + * @var int + */ + private $startupTime = 0; + + /** + * TcpSocket constructor. + * @param EventLoop\LoopInterface $loop + * @param MessageComponentInterface $handler + * @param Store $store + * @param string $acceptType + * @param float $waitTimeout + * @param bool $endWithResponse + */ + public function __construct( + EventLoop\LoopInterface $loop, + MessageComponentInterface $handler, + Store $store, + string $acceptType = self::DEFAULT_ACCEPT_TYPE, + float $waitTimeout = self::DEFAULT_WAIT_TIMEOUT, + bool $endWithResponse = self::DEFAULT_END_WITH_RESPONSE + ){ + parent::__construct($loop, $handler, $store); + + $this->acceptType = $acceptType; + $this->waitTimeout = $waitTimeout; + $this->endWithResponse = $endWithResponse; + $this->connections = new \SplObjectStorage(); + $this->startupTime = time(); + } + + /** + * @param Socket\ConnectionInterface $connection + */ + public function onConnect(Socket\ConnectionInterface $connection){ + $this->log('debug', $connection, __FUNCTION__, 'open connection…'); + + if($this->isValidConnection($connection)){ + // connection can be used + // add connection to global connection pool + $this->addConnection($connection); + // set waitTimeout timer for connection + $this->setTimerTimeout($connection, $this->waitTimeout); + + // register connection events ... ------------------------------------------------------------------------- + $this->initRead($connection) + ->then($this->initDispatch($connection)) + ->then($this->initResponse($connection)) + ->then( + function(array $payload) use ($connection) { + $this->log(['debug', 'info'], $connection,'DONE', 'task "' . $payload['task'] . '" done → response send'); + }, + function(\Exception $e) use ($connection) { + $this->log(['debug', 'error'], $connection, 'ERROR', $e->getMessage()); + $this->connectionError($connection, $e); + }); + + $connection->on('end', function() use ($connection) { + $this->log('debug', $connection, 'onEnd'); + }); + + $connection->on('close', function() use ($connection) { + $this->log(['debug'], $connection, 'onClose', 'close connection'); + $this->removeConnection($connection); + }); + + $connection->on('error', function(\Exception $e) use ($connection) { + $this->log(['debug', 'error'], $connection, 'onError', $e->getMessage()); + }); + }else{ + // invalid connection -> can not be used + $connection->close(); + } + } + + /** + * @param Socket\ConnectionInterface $connection + * @return Promise\PromiseInterface + */ + protected function initRead(Socket\ConnectionInterface $connection) : Promise\PromiseInterface { + if($connection->isReadable()){ + if('json' == $this->acceptType){ + // new empty stream for processing JSON + $stream = new Stream\ThroughStream(); + $streamDecoded = new NDJson\Decoder($stream, true, 512, 0, self::JSON_DECODE_MAX_LENGTH); + + // promise get resolved on first emit('data') + $promise = Promise\Stream\first($streamDecoded); + + // register on('data') for main input stream + $connection->on('data', function ($chunk) use ($stream) { + // send current data chunk to processing stream -> resolves promise + $stream->emit('data', [$chunk]); + }); + + return $promise; + }else{ + return new Promise\RejectedPromise( + new \InvalidArgumentException( + sprintf(self::ERROR_ACCEPT_TYPE, $this->acceptType) + ) + ); + } + }else{ + return new Promise\RejectedPromise( + new \Exception( + sprintf(self::ERROR_STREAM_NOT_READABLE, $connection->getRemoteAddress()) + ) + ); + } + } + + /** + * init dispatcher for payload + * @param Socket\ConnectionInterface $connection + * @return callable + */ + protected function initDispatch(Socket\ConnectionInterface $connection) : callable { + return function(array $payload) use ($connection) : Promise\PromiseInterface { + $task = (string)$payload['task']; + if(!empty($task)){ + $load = $payload['load']; + $deferred = new Promise\Deferred(); + $this->dispatch($connection, $deferred, $task, $load); + return $deferred->promise(); + }else{ + return new Promise\RejectedPromise( + new \InvalidArgumentException(self::ERROR_TASK_MISSING) + ); + } + }; + } + + /** + * @param Socket\ConnectionInterface $connection + * @param Promise\Deferred $deferred + * @param string $task + * @param null $load + */ + protected function dispatch(Socket\ConnectionInterface $connection, Promise\Deferred $deferred, string $task, $load = null) : void { + $addStatusData = false; + + switch($task){ + case 'getStats': + $addStatusData = true; + $deferred->resolve($this->newPayload($task, null, $addStatusData)); + break; + case 'healthCheck': + $addStatusData = true; + case 'characterUpdate': + case 'characterLogout': + case 'mapConnectionAccess': + case 'mapAccess': + case 'mapUpdate': + case 'mapDeleted': + case 'logData': + if(method_exists($this->handler, 'receiveData')){ + $this->log(['info'], $connection, __FUNCTION__, 'task "' . $task . '" processing…'); + + $deferred->resolve( + $this->newPayload( + $task, + call_user_func_array([$this->handler, 'receiveData'], [$task, $load]), + $addStatusData + ) + ); + }else{ + $deferred->reject(new \Exception(sprintf(self::ERROR_METHOD_MISSING, 'receiveData'))); + } + break; + default: + $deferred->reject(new \InvalidArgumentException(sprintf(self::ERROR_TASK_UNKNOWN, $task))); + } + } + + /** + * @param Socket\ConnectionInterface $connection + * @return callable + */ + protected function initResponse(Socket\ConnectionInterface $connection) : callable { + return function(array $payload) use ($connection) : Promise\PromiseInterface { + $this->log('debug', $connection, 'initResponse', 'task "' . $payload['task'] . '" → init response'); + + $deferred = new Promise\Deferred(); + $this->write($deferred, $connection, $payload); + + return $deferred->promise(); + }; + } + + /** + * @param Promise\Deferred $deferred + * @param Socket\ConnectionInterface $connection + * @param array $payload + */ + protected function write(Promise\Deferred $deferred, Socket\ConnectionInterface $connection, array $payload) : void { + $write = false; + if($connection->isWritable()){ + if('json' == $this->acceptType){ + $connection = new NDJson\Encoder($connection); + } + + // write a new chunk of data to connection stream + $write = $connection->write($payload); + + if($this->endWithResponse){ + // connection should be closed (and removed from this socket server) + $connection->end(); + } + } + + if($write){ + $deferred->resolve($payload); + }else{ + $deferred->reject(new \Exception( + sprintf(self::ERROR_STREAM_NOT_WRITABLE, $connection->getRemoteAddress()) + )); + } + } + + /** + * $connection has error + * -> if writable -> end() connection with $payload (close() is called by default) + * -> if readable -> close() connection + * @param Socket\ConnectionInterface $connection + * @param \Exception $e + */ + protected function connectionError(Socket\ConnectionInterface $connection, \Exception $e){ + $errorMessage = $e->getMessage(); + $this->log(['debug', 'error'], $connection, __FUNCTION__, $errorMessage); + + if($connection->isWritable()){ + if('json' == $this->acceptType){ + $connection = new NDJson\Encoder($connection); + } + + // send "end" data, then close + $connection->end($this->newPayload('error', $errorMessage, true)); + }else{ + // close connection + $connection->close(); + } + } + + /** + * check if $connection is found in global pool + * @param Socket\ConnectionInterface $connection + * @return bool + */ + protected function hasConnection(Socket\ConnectionInterface $connection) : bool { + return $this->connections->contains($connection); + } + + /** + * cancels a previously set timer callback for a $connection + * @param Socket\ConnectionInterface $connection + * @param string $timerName + */ + protected function cancelTimer(Socket\ConnectionInterface $connection, string $timerName){ + if( + $this->hasConnection($connection) && + ($data = (array)$this->connections->offsetGet($connection)) && + isset($data['timers']) && isset($data['timers'][$timerName]) && + ($data['timers'][$timerName] instanceof EventLoop\TimerInterface) + ){ + $this->loop->cancelTimer($data['timers'][$timerName]); + + unset($data['timers'][$timerName]); + $this->connections->offsetSet($connection, $data); + } + } + + /** + * cancels all previously set timers for a $connection + * @param Socket\ConnectionInterface $connection + */ + protected function cancelTimers(Socket\ConnectionInterface $connection){ + if( + $this->hasConnection($connection) && + ($data = (array)$this->connections->offsetGet($connection)) && + isset($data['timers']) + ){ + foreach((array)$data['timers'] as $timerName => $timer){ + $this->loop->cancelTimer($timer); + } + + $data['timers'] = []; + $this->connections->offsetSet($connection, $data); + } + } + + /** + * @param Socket\ConnectionInterface $connection + * @param string $timerName + * @param float $interval + * @param callable $timerCallback + */ + protected function setTimer(Socket\ConnectionInterface $connection, string $timerName, float $interval, callable $timerCallback){ + if( + $this->hasConnection($connection) && + ($data = (array)$this->connections->offsetGet($connection)) && + isset($data['timers']) + ){ + $data['timers'][$timerName] = $this->loop->addTimer($interval, function() use ($connection, $timerCallback) { + $timerCallback($connection); + }); + + // store new timer to $connection + $this->connections->offsetSet($connection, $data); + } + } + + /** + * cancels and removes previous connection timeout timers + * -> set new connection timeout + * @param Socket\ConnectionInterface $connection + * @param float $waitTimeout + */ + protected function setTimerTimeout(Socket\ConnectionInterface $connection, float $waitTimeout = self::DEFAULT_WAIT_TIMEOUT){ + $this->cancelTimer($connection, 'disconnectTimer'); + $this->setTimer($connection, 'disconnectTimer', $waitTimeout, function(Socket\ConnectionInterface $connection) use ($waitTimeout) { + $errorMessage = sprintf(self::ERROR_WAIT_TIMEOUT, $waitTimeout); + + $this->connectionError( + $connection, + new Promise\Timer\TimeoutException($waitTimeout, $errorMessage) + ); + }); + } + + /** + * add new connection to global pool + * @param Socket\ConnectionInterface $connection + */ + protected function addConnection(Socket\ConnectionInterface $connection){ + if(!$this->hasConnection($connection)){ + $this->connections->attach($connection, [ + 'remoteAddress' => $connection->getRemoteAddress(), + 'timers' => [] + ]); + + // update maxConnections count + $this->maxConnections = max($this->connections->count(), $this->maxConnections); + + $this->log(['debug'], $connection, __FUNCTION__, 'add new connection'); + }else{ + $this->log(['debug'], $connection, __FUNCTION__, 'connection already exists'); + } + } + + /** + * remove $connection from global connection pool + * @param Socket\ConnectionInterface $connection + */ + protected function removeConnection(Socket\ConnectionInterface $connection){ + if($this->hasConnection($connection)){ + $this->log(['debug'], $connection, __FUNCTION__, 'remove connection'); + $this->cancelTimers($connection); + $this->connections->detach($connection); + } + } + + /** + * get new payload + * @param string $task + * @param null $load + * @param bool $addStats + * @return array + */ + protected function newPayload(string $task, $load = null, bool $addStats = false) : array { + $payload = [ + 'task' => $task, + 'load' => $load + ]; + + if($addStats || $this->addStats){ + // add socket statistics + $payload['stats'] = $this->getStats(); + } + + return $payload; + } + + /** + * check if connection is "valid" and can be used for data transfer + * @param Socket\ConnectionInterface $connection + * @return bool + */ + protected function isValidConnection(Socket\ConnectionInterface $connection) : bool { + return $connection->isReadable() || $connection->isWritable(); + } + + /** + * get socket server statistics + * -> e.g. connected clients count + * @return array + */ + protected function getStats() : array { + return [ + 'tcpSocket' => $this->getSocketStats(), + 'webSocket' => $this->handler->getSocketStats() + ]; + } + + /** + * get TcpSocket stats data + * @return array + */ + protected function getSocketStats() : array { + return [ + 'startup' => time() - $this->startupTime, + 'connections' => $this->connections->count(), + 'maxConnections' => $this->maxConnections, + 'logs' => array_reverse($this->logStore->getStore()) + ]; + } +} \ No newline at end of file diff --git a/websocket/app/WebSockets.php b/websocket/app/WebSockets.php new file mode 100644 index 0000000..acc9730 --- /dev/null +++ b/websocket/app/WebSockets.php @@ -0,0 +1,113 @@ +dsn = $dsn; + $this->wsListenPort = $wsListenPort; + $this->wsListenHost = $wsListenHost; + $this->debug = $debug; + + $this->startMapSocket(); + } + + private function startMapSocket(){ + // global EventLoop + $loop = EventLoop\Factory::create(); + + // new Stores for logging ------------------------------------------------------------------------------------- + $webSocketLogStore = new Store(Component\MapUpdate::COMPONENT_NAME); + $webSocketLogStore->setLogLevel($this->debug); + + $tcpSocketLogStore = new Store(TcpSocket::COMPONENT_NAME); + $tcpSocketLogStore->setLogLevel($this->debug); + + // global MessageComponent (main app) (handles all business logic) -------------------------------------------- + $mapUpdate = new Component\MapUpdate($webSocketLogStore); + + $loop->addPeriodicTimer(3, function(EventLoop\TimerInterface $timer) use ($mapUpdate) { + $mapUpdate->housekeeping($timer); + }); + + // TCP Socket ------------------------------------------------------------------------------------------------- + $tcpSocket = new TcpSocket($loop, $mapUpdate, $tcpSocketLogStore); + // TCP Server (WebServer <-> TCPServer <-> TCPSocket communication) + $server = new Socket\Server($this->dsn, $loop, [ + 'tcp' => [ + 'backlog' => 20, + 'so_reuseport' => true + ] + ]); + + $server->on('connection', function(Socket\ConnectionInterface $connection) use ($tcpSocket) { + $tcpSocket->onConnect($connection); + }); + + $server->on('error', function(\Exception $e) use ($tcpSocket) { + $tcpSocket->log(['debug', 'error'], null, 'onError', $e->getMessage()); + }); + + // WebSocketServer -------------------------------------------------------------------------------------------- + + // Binding to 0.0.0.0 means remotes can connect (Web Clients) + $webSocketURI = $this->wsListenHost . ':' . $this->wsListenPort; + + // Set up our WebSocket server for clients subscriptions + $webSock = new Socket\TcpServer($webSocketURI, $loop); + new IoServer( + new HttpServer( + new WsServer( + $mapUpdate + ) + ), + $webSock + ); + + $loop->run(); + } + +} \ No newline at end of file diff --git a/websocket/cmd.php b/websocket/cmd.php new file mode 100644 index 0000000..a4ebb32 --- /dev/null +++ b/websocket/cmd.php @@ -0,0 +1,91 @@ + default values + // The default values should be fine for 99% of you! + $longOpts = [ + 'wsHost:' => '0.0.0.0', // WebSocket connection (for WebClients => Browser). '0.0.0.0' <-- any client can connect! + 'wsPort:' => 8020, // ↪ default WebSocket URI: 127.0.0.1:8020. This is where Nginx must proxy WebSocket traffic to + 'tcpHost:' => '127.0.0.1', // TcpSocket connection (for WebServer ⇄ WebSocket) + 'tcpPort:' => 5555, // ↪ default TcpSocket URI: tcp://127.0.0.1:5555 + 'debug:' => 2 // Debug level [0-3] 0 = silent, 1 = errors, 2 = error + info, 3 = error + info + debug + ]; + + // get options from CLI parameter + default values + $cliOpts = getopt('', array_keys($longOpts)); + + $options = []; + array_walk($longOpts, function($defaultVal, $optKey) use ($cliOpts, &$options) { + $key = trim($optKey, ':'); + $val = $defaultVal; + if(array_key_exists($key, $cliOpts)){ + $val = is_int($defaultVal) ? (int)$cliOpts[$key] : $cliOpts[$key] ; + } + $options[$key] = $val; + }); + + /** + * print current config parameters to Shell + * @param array $longOpts + * @param array $options + */ + $showHelp = function(array $longOpts, array $options){ + $optKeys = array_keys($longOpts); + $colors = new Socket\Log\ShellColors(); + $data = []; + + // headline for CLI config parameters + $rowData = $colors->getColoredString(str_pad(' param', 12), 'white'); + $rowData .= $colors->getColoredString(str_pad('value', 18, ' ', STR_PAD_LEFT), 'white'); + $rowData .= $colors->getColoredString(str_pad('default', 15, ' ', STR_PAD_LEFT), 'white'); + + $data[] = $rowData; + $data[] = str_pad(' ', 45, '-'); + + $i = 0; + foreach($options as $optKey => $optVal){ + $rowData = $colors->getColoredString(str_pad(' -' . $optKey, 12), 'yellow'); + $rowData .= $colors->getColoredString(str_pad($optVal, 18, ' ', STR_PAD_LEFT), 'light_purple'); + $rowData .= $colors->getColoredString(str_pad($longOpts[$optKeys[$i]], 15, ' ', STR_PAD_LEFT), 'dark_gray'); + $data[] = $rowData; + $i++; + } + $data[] = ''; + + echo implode(PHP_EOL, $data) . PHP_EOL; + }; + + /** + * set error reporting based on debug option value + * @param int $debug + */ + $setErrorReporting = function(int $debug){ + switch($debug){ + case 0: error_reporting(0); break; // Turn off all error reporting + case 1: error_reporting(E_ERROR); break; // Errors only + case 2: error_reporting(E_ALL & ~E_NOTICE); break; // Report all errors except E_NOTICE + default: error_reporting(E_ALL); + } + }; + + $setErrorReporting($options['debug']); + + if($options['debug']){ + // print if -debug > 0 + $showHelp($longOpts, $options); + } + + $dsn = 'tcp://' . $options['tcpHost'] . ':' . $options['tcpPort']; + + new Socket\WebSockets($dsn, $options['wsPort'], $options['wsHost'], $options['debug']); + +}else{ + echo "Script need to be called by CLI!"; +} + + + diff --git a/websocket/composer.json b/websocket/composer.json new file mode 100644 index 0000000..da4cf83 --- /dev/null +++ b/websocket/composer.json @@ -0,0 +1,27 @@ +{ + "name": "exodus4d/pathfinder_websocket", + "description": "WebSocket extension for 'Pathfinder'", + "minimum-stability": "stable", + "license": "MIT", + "authors": [ + { + "name": "Mark Friedrich", + "email": "pathfinder@exodus4d.de" + } + ], + "autoload": { + "psr-4": { + "Exodus4D\\Socket\\": "app/" + } + }, + "require": { + "php-64bit": ">=7.1", + "ext-json": "*", + "cboden/ratchet": "0.4.x", + "react/promise-stream": "1.2.*", + "clue/ndjson-react": "1.1.*" + }, + "suggest": { + "ext-event": "If installed, 'ExtEventLoop' class will get used as default event loop. Better performance. https://pecl.php.net/package/event" + } +} \ No newline at end of file diff --git a/websocket/composer.lock b/websocket/composer.lock new file mode 100644 index 0000000..712c634 --- /dev/null +++ b/websocket/composer.lock @@ -0,0 +1,1120 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3ab6b63f00cc99da3ef0ca2500f96d9b", + "packages": [ + { + "name": "cboden/ratchet", + "version": "v0.4.2", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/Ratchet.git", + "reference": "57721e1f184f9e29378fc5363867c47ddda743fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/Ratchet/zipball/57721e1f184f9e29378fc5363867c47ddda743fd", + "reference": "57721e1f184f9e29378fc5363867c47ddda743fd", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^1.0", + "php": ">=5.4.2", + "ratchet/rfc6455": "^0.2", + "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", + "symfony/http-foundation": "^2.6|^3.0|^4.0|^5.0", + "symfony/routing": "^2.6|^3.0|^4.0|^5.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\": "src/Ratchet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + } + ], + "description": "PHP WebSocket library", + "homepage": "http://socketo.me", + "keywords": [ + "Ratchet", + "WebSockets", + "server", + "sockets", + "websocket" + ], + "time": "2020-01-27T23:08:40+00:00" + }, + { + "name": "clue/ndjson-react", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "767ec9543945802b5766fab0da4520bf20626f66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/767ec9543945802b5766fab0da4520bf20626f66", + "reference": "767ec9543945802b5766fab0da4520bf20626f66", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.0 || ^0.7 || ^0.6" + }, + "require-dev": { + "phpunit/phpunit": "^7.0 || ^6.0 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "time": "2020-02-04T11:48:52+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "531bfb9d15f8aa57454f5f0285b18bec903b8fb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/531bfb9d15f8aa57454f5f0285b18bec903b8fb7", + "reference": "531bfb9d15f8aa57454f5f0285b18bec903b8fb7", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Evenement": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "time": "2017-07-23T21:35:13+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "time": "2019-07-01T23:21:34+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ratchet/rfc6455", + "version": "v0.2.6", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "879e48c840f8dbc296d68d6a5030673df79bd916" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/879e48c840f8dbc296d68d6a5030673df79bd916", + "reference": "879e48c840f8dbc296d68d6a5030673df79bd916", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^1.0", + "php": ">=5.4.2" + }, + "require-dev": { + "phpunit/phpunit": "4.8.*", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "time": "2019-12-15T10:18:18+00:00" + }, + { + "name": "react/cache", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "aa10d63a1b40a36a486bdf527f28bac607ee6466" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/aa10d63a1b40a36a486bdf527f28bac607ee6466", + "reference": "aa10d63a1b40a36a486bdf527f28bac607ee6466", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "~2.0|~1.1" + }, + "require-dev": { + "phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "time": "2019-07-11T13:45:28+00:00" + }, + { + "name": "react/dns", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "a214d90c2884dac18d0cac6176202f247b66d762" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/a214d90c2884dac18d0cac6176202f247b66d762", + "reference": "a214d90c2884dac18d0cac6176202f247b66d762", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.0 || ^0.5", + "react/promise": "^2.7 || ^1.2.1", + "react/promise-timer": "^1.2" + }, + "require-dev": { + "clue/block-react": "^1.2", + "phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "time": "2019-08-15T09:06:31+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "6d24de090cd59cfc830263cfba965be77b563c13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/6d24de090cd59cfc830263cfba965be77b563c13", + "reference": "6d24de090cd59cfc830263cfba965be77b563c13", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35" + }, + "suggest": { + "ext-event": "~1.0 for ExtEventLoop", + "ext-pcntl": "For signal handling support when using the StreamSelectLoop", + "ext-uv": "* for ExtUvLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "time": "2020-01-01T18:39:52+00:00" + }, + { + "name": "react/promise", + "version": "v2.7.1", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "31ffa96f8d2ed0341a57848cbb84d88b89dd664d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/31ffa96f8d2ed0341a57848cbb84d88b89dd664d", + "reference": "31ffa96f8d2ed0341a57848cbb84d88b89dd664d", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "time": "2019-01-07T21:25:54+00:00" + }, + { + "name": "react/promise-stream", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-stream.git", + "reference": "6384d8b76cf7dcc44b0bf3343fb2b2928412d1fe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-stream/zipball/6384d8b76cf7dcc44b0bf3343fb2b2928412d1fe", + "reference": "6384d8b76cf7dcc44b0bf3343fb2b2928412d1fe", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/promise": "^2.1 || ^1.2", + "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.6" + }, + "require-dev": { + "clue/block-react": "^1.0", + "phpunit/phpunit": "^7.0 || ^6.4 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3", + "react/promise-timer": "^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Promise\\Stream\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "The missing link between Promise-land and Stream-land for ReactPHP", + "homepage": "https://github.com/reactphp/promise-stream", + "keywords": [ + "Buffer", + "async", + "promise", + "reactphp", + "stream", + "unwrap" + ], + "time": "2019-07-03T12:29:10+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.5.1", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "35fb910604fd86b00023fc5cda477c8074ad0abc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/35fb910604fd86b00023fc5cda477c8074ad0abc", + "reference": "35fb910604fd86b00023fc5cda477c8074ad0abc", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3.5", + "react/promise": "^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Promise\\Timer\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "time": "2019-03-27T18:10:32+00:00" + }, + { + "name": "react/socket", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "97522e24987365e1ed873f0f4884900747a668e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/97522e24987365e1ed873f0f4884900747a668e0", + "reference": "97522e24987365e1ed873f0f4884900747a668e0", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.1", + "react/event-loop": "^1.0 || ^0.5", + "react/promise": "^2.6.0 || ^1.2.1", + "react/promise-timer": "^1.4.0", + "react/stream": "^1.1" + }, + "require-dev": { + "clue/block-react": "^1.2", + "phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35", + "react/promise-stream": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "time": "2020-03-12T12:15:14+00:00" + }, + { + "name": "react/stream", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "50426855f7a77ddf43b9266c22320df5bf6c6ce6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/50426855f7a77ddf43b9266c22320df5bf6c6ce6", + "reference": "50426855f7a77ddf43b9266c22320df5bf6c6ce6", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.0 || ^0.5 || ^0.4 || ^0.3.5" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "time": "2019-01-01T16:15:09+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v5.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "6f9c2ba72f4295d7ce6cf9f79dbb18036291d335" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6f9c2ba72f4295d7ce6cf9f79dbb18036291d335", + "reference": "6f9c2ba72f4295d7ce6cf9f79dbb18036291d335", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "symfony/mime": "^4.4|^5.0", + "symfony/polyfill-mbstring": "~1.1" + }, + "require-dev": { + "predis/predis": "~1.0", + "symfony/expression-language": "^4.4|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony HttpFoundation Component", + "homepage": "https://symfony.com", + "time": "2020-02-14T07:43:07+00:00" + }, + { + "name": "symfony/mime", + "version": "v5.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "9b3e5b5e58c56bbd76628c952d2b78556d305f3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/9b3e5b5e58c56bbd76628c952d2b78556d305f3c", + "reference": "9b3e5b5e58c56bbd76628c952d2b78556d305f3c", + "shasum": "" + }, + "require": { + "php": "^7.2.5", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/mailer": "<4.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10", + "symfony/dependency-injection": "^4.4|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A library to manipulate MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "time": "2020-02-04T09:41:09+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "6842f1a39cf7d580655688069a03dd7cd83d244a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/6842f1a39cf7d580655688069a03dd7cd83d244a", + "reference": "6842f1a39cf7d580655688069a03dd7cd83d244a", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.14-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "time": "2020-01-17T12:01:36+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/34094cfa9abe1f0f14f48f490772db7a775559f2", + "reference": "34094cfa9abe1f0f14f48f490772db7a775559f2", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.14-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2020-01-13T11:15:53+00:00" + }, + { + "name": "symfony/polyfill-php72", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php72.git", + "reference": "46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf", + "reference": "46ecacf4751dd0dc81e4f6bf01dbf9da1dc1dadf", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.14-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2020-01-13T11:15:53+00:00" + }, + { + "name": "symfony/routing", + "version": "v5.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "d6ca39fd05c1902bf34d724ba06fb8044a0b46de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/d6ca39fd05c1902bf34d724ba06fb8044a0b46de", + "reference": "d6ca39fd05c1902bf34d724ba06fb8044a0b46de", + "shasum": "" + }, + "require": { + "php": "^7.2.5" + }, + "conflict": { + "symfony/config": "<5.0", + "symfony/dependency-injection": "<4.4", + "symfony/yaml": "<4.4" + }, + "require-dev": { + "doctrine/annotations": "~1.2", + "psr/log": "~1.0", + "symfony/config": "^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/expression-language": "^4.4|^5.0", + "symfony/http-foundation": "^4.4|^5.0", + "symfony/yaml": "^4.4|^5.0" + }, + "suggest": { + "doctrine/annotations": "For using the annotation loader", + "symfony/config": "For using the all-in-one router or any loader", + "symfony/expression-language": "For using expression matching", + "symfony/http-foundation": "For using a Symfony Request object", + "symfony/yaml": "For using the YAML loader" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Routing Component", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "time": "2020-02-25T14:24:11+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php-64bit": ">=7.1", + "ext-json": "*" + }, + "platform-dev": [] +}