private fork from github

This commit is contained in:
soneill 2020-08-30 19:59:19 +12:00
commit ddcacca5c3
37 changed files with 5748 additions and 0 deletions

10
.env.example Normal file
View file

@ -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"

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

6
.gitmodules vendored Normal file
View file

@ -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

46
Dockerfile Normal file
View file

@ -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"]

24
LICENSE.md Normal file
View file

@ -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.

71
README.md Normal file
View file

@ -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

View file

@ -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

View file

@ -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

93
docker-compose.yml Normal file
View file

@ -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:

1
pathfinder Submodule

@ -0,0 +1 @@
Subproject commit c3f0bb2eccfd39cb5cd2eee37db3f005e1718630

128
setup.sh Executable file
View file

@ -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

2
static/crontab.txt Executable file
View file

@ -0,0 +1,2 @@
* * * * * cd /var/www/html/pathfinder;sudo -u nobody php index.php /cron >> /var/log/cron.log 2>&1

9
static/entrypoint.sh Normal file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
envsubst '$DOMAIN' </etc/nginx/sites_enabled/templateSite.conf >/etc/nginx/sites_enabled/site.conf
envsubst '$CONTAINER_NAME' </etc/nginx/templateNginx.conf >/etc/nginx/nginx.conf
envsubst </var/www/html/pathfinder/app/templateEnvironment.ini >/var/www/html/pathfinder/app/environment.ini
envsubst </var/www/html/pathfinder/app/templateConfig.ini >/var/www/html/pathfinder/app/config.ini
envsubst </etc/zzz_custom.ini >/etc/php7/conf.d/zzz_custom.ini
htpasswd -c -b -B /etc/nginx/.setup_pass pf "$APP_PASSWORD"
exec "$@"

451
static/menu.sh Normal file
View file

@ -0,0 +1,451 @@
#!/bin/bash
##
# Pure BASH interactive CLI/TUI menu (single and multi-select/checkboxes)
#
# Author: Markus Geiger <mg@evolution515.net>
# 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> 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> 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
}

247
static/nginx/nginx.conf Executable file
View file

@ -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;
}

89
static/nginx/site.conf Executable file
View file

@ -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;
}
}

View file

@ -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

View file

@ -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

40
static/php/fpm-pool.conf Executable file
View file

@ -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

14
static/php/php.ini Executable file
View file

@ -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"

24
static/supervisord.conf Executable file
View file

@ -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

17
websocket/.gitattributes vendored Normal file
View file

@ -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

11
websocket/.gitignore vendored Normal file
View file

@ -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

134
websocket/README.md Normal file
View file

@ -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"

View file

@ -0,0 +1,269 @@
<?php
namespace Exodus4D\Socket\Component;
use Exodus4D\Socket\Data\Payload;
use Exodus4D\Socket\Log\Store;
use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
use React\EventLoop\TimerInterface;
abstract class AbstractMessageComponent implements MessageComponentInterface {
/**
* unique name for this component
* -> 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 {
}
}

View file

@ -0,0 +1,41 @@
<?php
/**
* Created by PhpStorm.
* User: exodu
* Date: 31.03.2018
* Time: 13:09
*/
namespace Exodus4D\Socket\Component\Formatter;
class SubscriptionFormatter{
/**
* group charactersData by systemId based on their current 'log' data
* @param array $charactersData
* @return array
*/
static function groupCharactersDataBySystem(array $charactersData) : array {
$data = [];
foreach($charactersData as $characterId => $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;
}
}

View file

@ -0,0 +1,76 @@
<?php
/**
* Created by PhpStorm.
* User: exodu
* Date: 03.09.2017
* Time: 17:02
*/
namespace Exodus4D\Socket\Component\Handler;
class LogFileHandler {
const ERROR_DIR_CREATE = 'There is no existing directory at "%s" and its not buildable.';
/**
* steam uri
* @var string
*/
private $stream = '';
/**
* stream dir
* @var string
*/
private $dir = '.';
/**
* file base dir already created
* @var bool
*/
private $dirCreated = false;
public function __construct(string $stream){
$this->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;
}
}

View file

@ -0,0 +1,943 @@
<?php
/**
* Created by PhpStorm.
* User: Exodus
* Date: 02.12.2016
* Time: 22:29
*/
namespace Exodus4D\Socket\Component;
use Exodus4D\Socket\Component\Handler\LogFileHandler;
use Exodus4D\Socket\Component\Formatter\SubscriptionFormatter;
use Exodus4D\Socket\Data\Payload;
use Exodus4D\Socket\Log\Store;
use Ratchet\ConnectionInterface;
class MapUpdate extends AbstractMessageComponent {
/**
* unique name for this component
* -> 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);
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace Exodus4D\Socket\Data;
/**
* Class Payload
* @package Exodus4D\Socket\Data
* @property string $task
* @property mixed $load
*/
class Payload implements \JsonSerializable {
/**
* error message for missing 'task' name
*/
const ERROR_TASK_MISSING = "'task' must be a not empty string";
/**
* task name
* @var string
*/
private $task = '';
/**
* payload data
* @var mixed
*/
private $load;
/**
* optional characterId array -> 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);
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Exodus4D\Socket\Log;
class ShellColors {
/**
* all foreground color codes
* @var array
*/
private $foregroundColors = [];
/**
* all background color codes
* @var array
*/
private $backgroundColors = [];
public function __construct() {
// set up "Shell" colors
$this->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);
}
}

220
websocket/app/Log/Store.php Normal file
View file

@ -0,0 +1,220 @@
<?php
namespace Exodus4D\Socket\Log;
class Store {
/**
* default for: unique store name
*/
const DEFAULT_NAME = 'store';
/**
* default for: echo log data in terminal
*/
const DEFAULT_LOG_TO_STDOUT = true;
/**
* default for: max cached log entries
*/
const DEFAULT_LOG_STORE_SIZE = 50;
/**
* @see Store::DEFAULT_NAME
* @var string
*/
private $name = self::DEFAULT_NAME;
/**
* log store for log entries
* -> 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, '.', ''));
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Exodus4D\Socket\Socket;
use Exodus4D\Socket\Log\Store;
use React\EventLoop;
use React\Socket;
use Ratchet\MessageComponentInterface;
abstract class AbstractSocket {
/**
* unique name for this component
* -> 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);
}
}
}

View file

@ -0,0 +1,551 @@
<?php
/**
* Created by PhpStorm.
* User: Exodus 4D
* Date: 15.02.2019
* Time: 14:29
*/
namespace Exodus4D\Socket\Socket;
use Exodus4D\Socket\Log\Store;
use React\EventLoop;
use React\Socket;
use React\Promise;
use React\Stream;
use Clue\React\NDJson;
use Ratchet\MessageComponentInterface;
class TcpSocket extends AbstractSocket{
/**
* unique name for this component
* -> 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())
];
}
}

View file

@ -0,0 +1,113 @@
<?php
/**
* Created by PhpStorm.
* User: Exodus
* Date: 01.11.2016
* Time: 18:21
*/
namespace Exodus4D\Socket;
use Exodus4D\Socket\Log\Store;
use Exodus4D\Socket\Socket\TcpSocket;
use React\EventLoop;
use React\Socket;
use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
class WebSockets {
/**
* @var string
*/
protected $dsn;
/**
* @var int
*/
protected $wsListenPort;
/**
* @var string
*/
protected $wsListenHost;
/**
* @var int
*/
protected $debug;
/**
* WebSockets constructor.
* @param string $dsn
* @param int $wsListenPort
* @param string $wsListenHost
* @param int $debug
*/
function __construct(string $dsn, int $wsListenPort, string $wsListenHost, int $debug = 1){
$this->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();
}
}

91
websocket/cmd.php Normal file
View file

@ -0,0 +1,91 @@
<?php
require 'vendor/autoload.php';
use Exodus4D\Socket;
if(PHP_SAPI === 'cli'){
// optional CLI params -> 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!";
}

27
websocket/composer.json Normal file
View file

@ -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"
}
}

1120
websocket/composer.lock generated Normal file

File diff suppressed because it is too large Load diff