diff --git a/bin/quickstart.sh b/bin/quickstart.sh index 40c25b6f..86d4f1a2 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,104 +1,101 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" test ! -z "$(php --modules | grep swoole)" || \ die "Is swoole installed?" base_dir=$(dirname $(dirname $0)) -docker pull docker.io/kolab/centos7:latest - -docker-compose down --remove-orphans -docker-compose build - -pushd ${base_dir}/src/ - # Always reset .env with .env.example -cp .env.example .env +cp src/.env.example src/.env -if [ -f ".env.local" ]; then +if [ -f "src/.env.local" ]; then # Ensure there's a line ending - echo "" >> .env - cat .env.local >> .env + echo "" >> src/.env + cat src/.env.local >> src/.env fi -popd +docker pull docker.io/kolab/centos7:latest + +docker-compose down --remove-orphans +docker-compose build bin/regen-certs docker-compose up -d coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis pushd ${base_dir}/src/ rm -rf vendor/ composer.lock php -dmemory_limit=-1 /bin/composer install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate ./artisan clear-compiled ./artisan cache:clear ./artisan horizon:install if [ ! -f storage/oauth-public.key -o ! -f storage/oauth-private.key ]; then ./artisan passport:keys --force fi cat >> .env << EOF PASSPORT_PRIVATE_KEY="$(cat storage/oauth-private.key)" PASSPORT_PUBLIC_KEY="$(cat storage/oauth-public.key)" EOF if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d worker nginx pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan data:import ./artisan swoole:http stop >/dev/null 2>&1 || : -./artisan swoole:http start +SWOOLE_HTTP_DAEMONIZE=true ./artisan swoole:http start +./artisan horizon popd diff --git a/docker/swoole/Dockerfile b/docker/swoole/Dockerfile index 546d01d0..20612ae2 100644 --- a/docker/swoole/Dockerfile +++ b/docker/swoole/Dockerfile @@ -1,66 +1,67 @@ FROM fedora:34 MAINTAINER Jeroen van Meeuwen -ARG SWOOLE_VERSION=4.6.x +ARG SWOOLE_VERSION=v4.6.7 ENV HOME=/opt/app-root/src LABEL io.k8s.description="Platform for serving PHP applications under Swoole" \ io.k8s.display-name="Swoole ${SWOOLE_VERSION}" \ io.openshift.expose-services="8000:http" \ io.openshift.tags="builder,php,swoole" RUN dnf -y install \ composer \ diffutils \ file \ git \ make \ npm \ openssl-devel \ patch \ php-cli \ php-common \ php-devel \ php-ldap \ php-opcache \ php-pecl-apcu \ php-mysqlnd \ re2c \ wget && \ - git clone -b v${SWOOLE_VERSION} https://github.com/swoole/swoole-src.git/ /swoole-src.git/ && \ + git clone https://github.com/swoole/swoole-src.git/ /swoole-src.git/ && \ cd /swoole-src.git/ && \ + git checkout -f ${SWOOLE_VERSION} && \ git clean -d -f -x && \ phpize --clean && \ phpize && \ ./configure \ --enable-sockets \ --disable-mysqlnd \ --enable-http2 \ --enable-openssl && \ make -j4 && \ make install && \ cd / && \ rm -rf /swoole-src.git/ && \ dnf -y remove \ diffutils \ file \ make \ openssl-devel \ php-devel \ re2c && \ dnf clean all && \ echo "extension=swoole.so" >> /etc/php.d/swoole.ini && \ php -m 2>&1 | grep -q swoole RUN id default || (groupadd -g 1001 default && useradd -d /opt/app-root/ -u 1001 -g 1001 default) USER 1001 WORKDIR ${HOME} COPY /rootfs / EXPOSE 8000 CMD [ "/usr/local/bin/usage" ] diff --git a/extras/kolab_policy_greylist.py b/extras/kolab_policy_greylist similarity index 85% rename from extras/kolab_policy_greylist.py rename to extras/kolab_policy_greylist index c26186ee..2e5236a5 100755 --- a/extras/kolab_policy_greylist.py +++ b/extras/kolab_policy_greylist @@ -1,79 +1,81 @@ -#!/usr/bin/python3 +#!/usr/bin/python """ An example implementation of a policy service. """ import json import time import sys import requests def read_request_input(): """ Read a single policy request from sys.stdin, and return a dictionary containing the request. """ start_time = time.time() policy_request = {} end_of_request = False while not end_of_request: if (time.time() - start_time) >= 10: + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() sys.exit(0) request_line = sys.stdin.readline() if request_line.strip() == '': if 'request' in policy_request: end_of_request = True else: request_line = request_line.strip() request_key = request_line.split('=')[0] request_value = '='.join(request_line.split('=')[1:]) policy_request[request_key] = request_value return policy_request if __name__ == "__main__": URL = 'https://services.kolabnow.com/api/webhooks/policy/greylist' # Start the work while True: REQUEST = read_request_input() try: RESPONSE = requests.post( URL, data=REQUEST, verify=True ) # pylint: disable=broad-except except Exception: - print("action=DEFER_IF_PERMIT Temporary error, try again later.") - sys.exit(1) + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) try: R = json.loads(RESPONSE.text) # pylint: disable=broad-except except Exception: - sys.exit(1) + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) if 'prepend' in R: for prepend in R['prepend']: print("action=PREPEND {0}".format(prepend)) if RESPONSE.ok: print("action={0}\n".format(R['response'])) - - sys.stdout.flush() else: print("action={0} {1}\n".format(R['response'], R['reason'])) - sys.stdout.flush() - + sys.stdout.flush() sys.exit(0) diff --git a/extras/kolab_policy_ratelimit b/extras/kolab_policy_ratelimit new file mode 100755 index 00000000..b5fb1a40 --- /dev/null +++ b/extras/kolab_policy_ratelimit @@ -0,0 +1,144 @@ +#!/usr/bin/python3 +""" +This policy applies rate limitations +""" + +import json +import time +import sys + +import requests + + +class PolicyRequest: + """ + A holder of policy request instances. + """ + db = None + recipients = [] + sender = None + + def __init__(self, request): + """ + Initialize a policy request, usually in RCPT protocol state. + """ + if 'sender' in request: + self.sender = request['sender'] + + if 'recipient' in request: + request['recipient'] = request['recipient'] + + self.recipients.append(request['recipient']) + + def add_request(self, request): + """ + Add an additional request from an instance to the existing instance + """ + # Normalize email addresses (they may contain recipient delimiters) + if 'recipient' in request: + request['recipient'] = request['recipient'] + + if not request['recipient'].strip() == '': + self.recipients.append(request['recipient']) + + def check_rate(self): + """ + Check the rates at which this sender is hitting our mailserver. + """ + if self.sender == "": + return {'response': 'DUNNO'} + + try: + response = requests.post( + URL, + data={ + 'sender': self.sender, + 'recipients': self.recipients + }, + verify=True + ) + + # pylint: disable=broad-except + except Exception: + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) + + return response + + +def read_request_input(): + """ + Read a single policy request from sys.stdin, and return a dictionary + containing the request. + """ + start_time = time.time() + + policy_request = {} + end_of_request = False + + while not end_of_request: + if (time.time() - start_time) >= 10: + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) + + request_line = sys.stdin.readline() + + if request_line.strip() == '': + if 'request' in policy_request: + end_of_request = True + else: + request_line = request_line.strip() + request_key = request_line.split('=')[0] + request_value = '='.join(request_line.split('=')[1:]) + + policy_request[request_key] = request_value + + return policy_request + + +if __name__ == "__main__": + URL = 'https://services.kolabnow.com/api/webhooks/policy/ratelimit' + + POLICY_REQUESTS = {} + + # Start the work + while True: + POLICY_REQUEST = read_request_input() + + INSTANCE = POLICY_REQUEST['instance'] + + if INSTANCE in POLICY_REQUESTS: + POLICY_REQUESTS[INSTANCE].add_request(POLICY_REQUEST) + else: + POLICY_REQUESTS[INSTANCE] = PolicyRequest(POLICY_REQUEST) + + protocol_state = POLICY_REQUEST['protocol_state'].strip().lower() + + if not protocol_state == 'data': + print("action=DUNNO\n") + sys.stdout.flush() + + else: + RESPONSE = POLICY_REQUESTS[INSTANCE].check_rate() + + try: + R = json.loads(RESPONSE.text) + # pylint: disable=broad-except + except Exception: + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) + + if 'prepend' in R: + for prepend in R['prepend']: + print("action=PREPEND {0}".format(prepend)) + + if 'reason' in R: + print("action={0} {1}\n".format(R['response'], R['reason'])) + else: + print("action={0}\n".format(R['response'])) + + sys.stdout.flush() + sys.exit(0) diff --git a/extras/kolab_policy_ratelimit.py b/extras/kolab_policy_ratelimit.py deleted file mode 100755 index b459b257..00000000 --- a/extras/kolab_policy_ratelimit.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/python3 -""" -This policy applies rate limitations -""" - -import json -import time -import sys - -import requests - - -def read_request_input(): - """ - Read a single policy request from sys.stdin, and return a dictionary - containing the request. - """ - start_time = time.time() - - policy_request = {} - end_of_request = False - - while not end_of_request: - if (time.time() - start_time) >= 10: - sys.exit(0) - - request_line = sys.stdin.readline() - - if request_line.strip() == '': - if 'request' in policy_request: - end_of_request = True - else: - request_line = request_line.strip() - request_key = request_line.split('=')[0] - request_value = '='.join(request_line.split('=')[1:]) - - policy_request[request_key] = request_value - - return policy_request - - -if __name__ == "__main__": - URL = 'https://services.kolabnow.com/api/webhooks/policy/ratelimit' - - # Start the work - while True: - REQUEST = read_request_input() - - try: - RESPONSE = requests.post( - URL, - data=REQUEST, - verify=True - ) - # pylint: disable=broad-except - except Exception: - print("action=DEFER_IF_PERMIT Temporary error, try again later.") - sys.exit(1) - - try: - R = json.loads(RESPONSE.text) - # pylint: disable=broad-except - except Exception: - sys.exit(1) - - if 'prepend' in R: - for prepend in R['prepend']: - print("action=PREPEND {0}".format(prepend)) - - if RESPONSE.ok: - print("action={0}\n".format(R['response'])) - - sys.stdout.flush() - else: - print("action={0} {1}\n".format(R['response'], R['reason'])) - - sys.stdout.flush() - - sys.exit(0) diff --git a/extras/kolab_policy_spf.py b/extras/kolab_policy_spf similarity index 86% rename from extras/kolab_policy_spf.py rename to extras/kolab_policy_spf index d98baac8..8fd4a1e1 100755 --- a/extras/kolab_policy_spf.py +++ b/extras/kolab_policy_spf @@ -1,80 +1,82 @@ -#!/usr/bin/python3 +#!/usr/bin/python """ This is the implementation of a (postfix) MTA policy service to enforce the Sender Policy Framework. """ import json import time import sys import requests def read_request_input(): """ Read a single policy request from sys.stdin, and return a dictionary containing the request. """ start_time = time.time() policy_request = {} end_of_request = False while not end_of_request: if (time.time() - start_time) >= 10: + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() sys.exit(0) request_line = sys.stdin.readline() if request_line.strip() == '': if 'request' in policy_request: end_of_request = True else: request_line = request_line.strip() request_key = request_line.split('=')[0] request_value = '='.join(request_line.split('=')[1:]) policy_request[request_key] = request_value return policy_request if __name__ == "__main__": URL = 'https://services.kolabnow.com/api/webhooks/policy/spf' # Start the work while True: REQUEST = read_request_input() try: RESPONSE = requests.post( URL, data=REQUEST, verify=True ) # pylint: disable=broad-except except Exception: - print("action=DEFER_IF_PERMIT Temporary error, try again later.") - sys.exit(1) + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) try: R = json.loads(RESPONSE.text) # pylint: disable=broad-except except Exception: - sys.exit(1) + print("action=DEFER_IF_PERMIT Temporary error, try again later.\n") + sys.stdout.flush() + sys.exit(0) if 'prepend' in R: for prepend in R['prepend']: print("action=PREPEND {0}".format(prepend)) if RESPONSE.ok: print("action={0}\n".format(R['response'])) - - sys.stdout.flush() else: print("action={0} {1}\n".format(R['response'], R['reason'])) - sys.stdout.flush() - + sys.stdout.flush() sys.exit(0) diff --git a/src/.env.example b/src/.env.example index f2d1d8e2..d51a7ba6 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,178 +1,178 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin SIGNUP_LIMIT_EMAIL=0 SIGNUP_LIMIT_IP=0 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= -; Set these to IP addresses you serve WOAT with. -; Have the domain owner point _woat. NS RRs refer to ns0{1,2}. +# Set these to IP addresses you serve WOAT with. +# Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Console/Commands/Policy/Greylist/ExpungeCommand.php b/src/app/Console/Commands/Policy/Greylist/ExpungeCommand.php new file mode 100644 index 00000000..d2c13dad --- /dev/null +++ b/src/app/Console/Commands/Policy/Greylist/ExpungeCommand.php @@ -0,0 +1,36 @@ +subMonthsWithoutOverflow(6)) + ->delete(); + + \App\Policy\Greylist\Whitelist::where('updated_at', '<', \Carbon\Carbon::now()->subMonthsWithoutOverflow(6)) + ->delete(); + } +} diff --git a/src/app/Console/Commands/Policy/RateLimit/ExpungeCommand.php b/src/app/Console/Commands/Policy/RateLimit/ExpungeCommand.php new file mode 100644 index 00000000..0e35c5eb --- /dev/null +++ b/src/app/Console/Commands/Policy/RateLimit/ExpungeCommand.php @@ -0,0 +1,32 @@ +subMonthsWithoutOverflow(6))->delete(); + } +} diff --git a/src/app/Console/Commands/Policy/RateLimit/Whitelist/CreateCommand.php b/src/app/Console/Commands/Policy/RateLimit/Whitelist/CreateCommand.php new file mode 100644 index 00000000..14156748 --- /dev/null +++ b/src/app/Console/Commands/Policy/RateLimit/Whitelist/CreateCommand.php @@ -0,0 +1,61 @@ +argument('object'); + + if (strpos($object, '@') === false) { + $domain = $this->getDomain($object); + + if (!$domain) { + $this->error("No such domain {$object}"); + return 1; + } + + $id = $domain->id; + $type = \App\Domain::class; + } else { + $user = $this->getUser($object); + + if (!$user) { + $this->error("No such user {$user}"); + return 1; + } + + $id = $user->id; + $type = \App\User::class; + } + + \App\Policy\RateLimitWhitelist::create( + [ + 'whitelistable_id' => $id, + 'whitelistable_type' => $type + ] + ); + } +} diff --git a/src/app/Console/Commands/Policy/RateLimit/Whitelist/DeleteCommand.php b/src/app/Console/Commands/Policy/RateLimit/Whitelist/DeleteCommand.php new file mode 100644 index 00000000..4795fa52 --- /dev/null +++ b/src/app/Console/Commands/Policy/RateLimit/Whitelist/DeleteCommand.php @@ -0,0 +1,61 @@ +argument('object'); + + if (strpos($object, '@') === false) { + $domain = $this->getDomain($object); + + if (!$domain) { + $this->error("No such domain {$object}"); + return 1; + } + + $id = $domain->id; + $type = \App\Domain::class; + } else { + $user = $this->getUser($object); + + if (!$user) { + $this->error("No such user {$user}"); + return 1; + } + + $id = $user->id; + $type = \App\User::class; + } + + \App\Policy\RateLimitWhitelist::where( + [ + 'whitelistable_id' => $id, + 'whitelistable_type' => $type + ] + )->delete(); + } +} diff --git a/src/app/Console/Commands/Policy/RateLimit/Whitelist/ReadCommand.php b/src/app/Console/Commands/Policy/RateLimit/Whitelist/ReadCommand.php new file mode 100644 index 00000000..2a9b0f77 --- /dev/null +++ b/src/app/Console/Commands/Policy/RateLimit/Whitelist/ReadCommand.php @@ -0,0 +1,42 @@ +whitelistable; + + if ($whitelistable instanceof \App\Domain) { + $this->info("{$item->id}: {$item->whitelistable_type} {$whitelistable->namespace}"); + } elseif ($whitelistable instanceof \App\User) { + $this->info("{$item->id}: {$item->whitelistable_type} {$whitelistable->email}"); + } + } + ); + } +} diff --git a/src/app/Console/Commands/Policy/RateLimitsCommand.php b/src/app/Console/Commands/Policy/RateLimitsCommand.php new file mode 100644 index 00000000..5f40030e --- /dev/null +++ b/src/app/Console/Commands/Policy/RateLimitsCommand.php @@ -0,0 +1,13 @@ +input(); $request = new \App\Policy\Greylist\Request($data); $shouldDefer = $request->shouldDefer(); if ($shouldDefer) { return response()->json( ['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."], 403 ); } $prependGreylist = $request->headerGreylist(); $result = [ 'response' => 'DUNNO', 'prepend' => [$prependGreylist] ]; return response()->json($result, 200); } /* * Apply a sensible rate limitation to a request. * * @return \Illuminate\Http\JsonResponse */ public function ratelimit() { - /* - $data = [ - 'instance' => 'test.local.instance', - 'protocol_state' => 'RCPT', - 'sender' => 'sender@spf-pass.kolab.org', - 'client_name' => 'mx.kolabnow.com', - 'client_address' => '212.103.80.148', - 'recipient' => $this->domainOwner->email - ]; - - $response = $this->post('/api/webhooks/spf', $data); - */ -/* $data = \request()->input(); - // TODO: normalize sender address $sender = strtolower($data['sender']); - $alias = \App\UserAlias::where('alias', $sender)->first(); + if (strpos($sender, '+') !== false) { + list($local, $rest) = explode('+', $sender); + list($rest, $domain) = explode('@', $sender); + $sender = "{$local}@{$domain}"; + } + + list($local, $domain) = explode('@', $sender); + + if (in_array($sender, \config('app.ratelimit_whitelist', []))) { + return response()->json(['response' => 'DUNNO'], 200); + } + + // + // Examine the individual sender + // + $user = \App\User::where('email', $sender)->first(); - if (!$alias) { - $user = \App\User::where('email', $sender)->first(); + if (!$user) { + $alias = \App\UserAlias::where('alias', $sender)->first(); - if (!$user) { - // what's the situation here? + if (!$alias) { + // use HOLD, so that it is silent (as opposed to REJECT) + return response()->json(['response' => 'HOLD', 'reason' => 'Sender not allowed here.'], 403); } - } else { + $user = $alias->user; } - // TODO time-limit - $userRates = \App\Policy\Ratelimit::where('user_id', $user->id); + if ($user->isDeleted() || $user->isSuspended()) { + // use HOLD, so that it is silent (as opposed to REJECT) + return response()->json(['response' => 'HOLD', 'reason' => 'Sender deleted or suspended'], 403); + } + + // + // Examine the domain + // + $domain = \App\Domain::where('namespace', $domain)->first(); + + if (!$domain) { + // external sender through where this policy is applied + return response()->json(['response' => 'DUNNO'], 200); + } + + if ($domain->isDeleted() || $domain->isSuspended()) { + // use HOLD, so that it is silent (as opposed to REJECT) + return response()->json(['response' => 'HOLD', 'reason' => 'Sender domain deleted or suspended'], 403); + } + + // see if the user or domain is whitelisted + // use ./artisan policy:ratelimit:whitelist:create + $whitelist = \App\Policy\RateLimitWhitelist::where( + [ + 'whitelistable_type' => \App\User::class, + 'whitelistable_id' => $user->id + ] + )->orWhere( + [ + 'whitelistable_type' => \App\Domain::class, + 'whitelistable_id' => $domain->id + ] + )->exists(); + + if ($whitelist) { + return response()->json(['response' => 'DUNNO'], 200); + } + + // user nor domain whitelisted, continue scrutinizing request + $recipients = $data['recipients']; + sort($recipients); + + $recipientCount = count($recipients); + $recipientHash = hash('sha256', implode(',', $recipients)); - // TODO message vs. recipient limit - if ($userRates->count() > 10) { - // TODO + // + // Retrieve the wallet to get to the owner + // + $wallet = $user->wallet(); + + // wait, there is no wallet? + if (!$wallet) { + return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403); } - // this is the wallet to which the account is billed - $wallet = $user->wallet; + $owner = $wallet->owner; - // TODO: consider $wallet->payments; + // find or create the request + $request = \App\Policy\RateLimit::where( + [ + 'recipient_hash' => $recipientHash, + 'user_id' => $user->id + ] + )->where('updated_at', '>=', \Carbon\Carbon::now()->subHour())->first(); - $owner = $wallet->user; + if (!$request) { + $request = \App\Policy\RateLimit::create( + [ + 'user_id' => $user->id, + 'owner_id' => $owner->id, + 'recipient_hash' => $recipientHash, + 'recipient_count' => $recipientCount + ] + ); + + // ensure the request has an up to date timestamp + } else { + $request->updated_at = \Carbon\Carbon::now(); + $request->save(); + } - // TODO time-limit - $ownerRates = \App\Policy\Ratelimit::where('owner_id', $owner->id); + // excempt owners that have made at least two payments and currently maintain a positive balance. + $payments = $wallet->payments + ->where('amount', '>', 0) + ->where('status', 'paid'); - // TODO message vs. recipient limit (w/ user counts) - if ($ownerRates->count() > 10) { - // TODO + if ($payments->count() >= 2 && $wallet->balance > 0) { + return response()->json(['response' => 'DUNNO'], 200); } -*/ + + // + // Examine the rates at which the owner (or its users) is sending + // + $ownerRates = \App\Policy\RateLimit::where('owner_id', $owner->id) + ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()); + + if ($ownerRates->count() >= 10) { + $result = [ + 'response' => 'DEFER_IF_PERMIT', + 'reason' => 'The account is at 10 messages per hour, cool down.' + ]; + + // automatically suspend (recursively) if 2.5 times over the original limit and younger than two months + $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); + + if ($ownerRates->count() >= 25 && $owner->created_at > $ageThreshold) { + $wallet->entitlements->each( + function ($entitlement) { + if ($entitlement->entitleable_type == \App\Domain::class) { + $entitlement->entitleable->suspend(); + } + + if ($entitlement->entitleable_type == \App\User::class) { + $entitlement->entitleable->suspend(); + } + } + ); + } + + return response()->json($result, 403); + } + + $ownerRates = \App\Policy\RateLimit::where('owner_id', $owner->id) + ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()) + ->sum('recipient_count'); + + if ($ownerRates >= 100) { + $result = [ + 'response' => 'DEFER_IF_PERMIT', + 'reason' => 'The account is at 100 recipients per hour, cool down.' + ]; + + // automatically suspend if 2.5 times over the original limit and younger than two months + $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); + + if ($ownerRates >= 250 && $owner->created_at > $ageThreshold) { + $wallet->entitlements->each( + function ($entitlement) { + if ($entitlement->entitleable_type == \App\Domain::class) { + $entitlement->entitleable->suspend(); + } + + if ($entitlement->entitleable_type == \App\User::class) { + $entitlement->entitleable->suspend(); + } + } + ); + } + + return response()->json($result, 403); + } + + // + // Examine the rates at which the user is sending (if not also the owner + // + if ($user->id != $owner->id) { + $userRates = \App\Policy\RateLimit::where('user_id', $user->id) + ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()); + + if ($userRates->count() >= 10) { + $result = [ + 'response' => 'DEFER_IF_PERMIT', + 'reason' => 'User is at 10 messages per hour, cool down.' + ]; + + // automatically suspend if 2.5 times over the original limit and younger than two months + $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); + + if ($userRates->count() >= 25 && $user->created_at > $ageThreshold) { + $user->suspend(); + } + + return response()->json($result, 403); + } + + $userRates = \App\Policy\RateLimit::where('user_id', $user->id) + ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()) + ->sum('recipient_count'); + + if ($userRates >= 100) { + $result = [ + 'response' => 'DEFER_IF_PERMIT', + 'reason' => 'User is at 100 recipients per hour, cool down.' + ]; + + // automatically suspend if 2.5 times over the original limit + $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); + + if ($userRates >= 250 && $user->created_at > $ageThreshold) { + $user->suspend(); + } + + return response()->json($result, 403); + } + } + + $result = [ + 'response' => 'DUNNO' + ]; + + return response()->json($result, 200); } /* * Apply the sender policy framework to a request. * * @return \Illuminate\Http\JsonResponse */ public function senderPolicyFramework() { $data = \request()->input(); if (!array_key_exists('client_address', $data)) { \Log::error("SPF: Request without client_address: " . json_encode($data)); return response()->json( [ 'response' => 'DEFER_IF_PERMIT', - 'reason' => 'Temporary error. Please try again later (' . __LINE__ . ')' + 'reason' => 'Temporary error. Please try again later.' ], 403 ); } list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']); // This network can not be recognized. if (!$netID) { \Log::error("SPF: Request without recognizable network: " . json_encode($data)); return response()->json( [ 'response' => 'DEFER_IF_PERMIT', - 'reason' => 'Temporary error. Please try again later (' . __LINE__ . ')' + 'reason' => 'Temporary error. Please try again later.' ], 403 ); } $senderLocal = 'unknown'; $senderDomain = 'unknown'; if (strpos($data['sender'], '@') !== false) { list($senderLocal, $senderDomain) = explode('@', $data['sender']); if (strlen($senderLocal) >= 255) { $senderLocal = substr($senderLocal, 0, 255); } } if ($data['sender'] === null) { $data['sender'] = ''; } // Compose the cache key we want. $cacheKey = "{$netType}_{$netID}_{$senderDomain}"; $result = \App\Policy\SPF\Cache::get($cacheKey); if (!$result) { $environment = new \SPFLib\Check\Environment( $data['client_address'], $data['client_name'], $data['sender'] ); $result = (new \SPFLib\Checker())->check($environment); \App\Policy\SPF\Cache::set($cacheKey, serialize($result)); } else { $result = unserialize($result); } $fail = false; $prependSPF = ''; switch ($result->getCode()) { case \SPFLib\Check\Result::CODE_ERROR_PERMANENT: $fail = true; $prependSPF = "Received-SPF: Permerror"; break; case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY: $prependSPF = "Received-SPF: Temperror"; break; case \SPFLib\Check\Result::CODE_FAIL: $fail = true; $prependSPF = "Received-SPF: Fail"; break; case \SPFLib\Check\Result::CODE_SOFTFAIL: $prependSPF = "Received-SPF: Softfail"; break; case \SPFLib\Check\Result::CODE_NEUTRAL: $prependSPF = "Received-SPF: Neutral"; break; case \SPFLib\Check\Result::CODE_PASS: $prependSPF = "Received-SPF: Pass"; break; case \SPFLib\Check\Result::CODE_NONE: $prependSPF = "Received-SPF: None"; break; } $prependSPF .= " identity=mailfrom;"; $prependSPF .= " client-ip={$data['client_address']};"; $prependSPF .= " helo={$data['client_name']};"; $prependSPF .= " envelope-from={$data['sender']};"; if ($fail) { // TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for // inbound mail to a local recipient address. $objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']); if (!empty($objects)) { // check if any of the recipient objects have whitelisted the helo, first one wins. foreach ($objects as $object) { if (method_exists($object, 'senderPolicyFrameworkWhitelist')) { $result = $object->senderPolicyFrameworkWhitelist($data['client_name']); if ($result) { $response = [ 'response' => 'DUNNO', 'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"], 'reason' => 'HELO name whitelisted' ]; return response()->json($response, 200); } } } } $result = [ 'response' => 'REJECT', 'prepend' => [$prependSPF], 'reason' => "Prohibited by Sender Policy Framework" ]; return response()->json($result, 403); } $result = [ 'response' => 'DUNNO', 'prepend' => [$prependSPF], 'reason' => "Don't know" ]; return response()->json($result, 200); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index 1ccbc466..9aab0129 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,104 +1,121 @@ namespace = \strtolower($domain->namespace); $domain->status |= Domain::STATUS_NEW; } /** * Handle the domain "created" event. * * @param \App\Domain $domain The domain. * * @return void */ public function created(Domain $domain) { // Create domain record in LDAP // Note: DomainCreate job will dispatch DomainVerify job \App\Jobs\Domain\CreateJob::dispatch($domain->id); } /** * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleted(Domain $domain) { if ($domain->isForceDeleting()) { return; } \App\Jobs\Domain\DeleteJob::dispatch($domain->id); } + /** + * Handle the domain "deleting" event. + * + * @param \App\Domain $domain The domain. + * + * @return void + */ + public function deleting(Domain $domain) + { + \App\Policy\RateLimitWhitelist::where( + [ + 'whitelistable_id' => $domain->id, + 'whitelistable_type' => Domain::class + ] + )->delete(); + } + /** * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ public function updated(Domain $domain) { \App\Jobs\Domain\UpdateJob::dispatch($domain->id); } /** * Handle the domain "restoring" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restoring(Domain $domain) { // Make sure it's not DELETED/LDAP_READY/SUSPENDED if ($domain->isDeleted()) { $domain->status ^= Domain::STATUS_DELETED; } if ($domain->isLdapReady()) { $domain->status ^= Domain::STATUS_LDAP_READY; } if ($domain->isSuspended()) { $domain->status ^= Domain::STATUS_SUSPENDED; } if ($domain->isConfirmed() && $domain->isVerified()) { $domain->status |= Domain::STATUS_ACTIVE; } // Note: $domain->save() is invoked between 'restoring' and 'restored' events } /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { // Create the domain in LDAP again \App\Jobs\Domain\CreateJob::dispatch($domain->id); } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 3dd9ca60..b0cbc916 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,275 +1,283 @@ email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { // Remove owned users/domains/groups/resources/etc self::removeRelatedObjects($user, $user->isForceDeleting()); // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? if (!$user->isForceDeleting()) { \App\Jobs\User\DeleteJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** * Handle the "updated" event. * * @param \App\User $user The user that is being updated. * * @return void */ public function updated(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); $oldStatus = $user->getOriginal('status'); $newStatus = $user->status; if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) { $wallets = []; $isDegraded = $user->isDegraded(); // Charge all entitlements as if they were being deleted, // but don't delete them. Just debit the wallet and update // entitlements' updated_at timestamp. On un-degrade we still // update updated_at, but with no debit (the cost is 0 on a degraded account). foreach ($user->wallets as $wallet) { $wallet->updateEntitlements($isDegraded); // Remember time of the degradation for sending periodic reminders // and reset it on un-degradation $val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null; $wallet->setSetting('degraded_last_reminder', $val); $wallets[] = $wallet->id; } // (Un-)degrade users by invoking an update job. // LDAP backend will read the wallet owner's degraded status and // set LDAP attributes accordingly. // We do not change their status as their wallets have its own state \App\Entitlement::whereIn('wallet_id', $wallets) ->where('entitleable_id', '!=', $user->id) ->where('entitleable_type', User::class) ->pluck('entitleable_id') ->unique() ->each(function ($user_id) { \App\Jobs\User\UpdateJob::dispatch($user_id); }); } } /** * Remove entitleables/transactions related to the user (in user's wallets) * * @param \App\User $user The user * @param bool $force Force-delete mode */ private static function removeRelatedObjects(User $user, $force = false): void { $wallets = $user->wallets->pluck('id')->all(); \App\Entitlement::withTrashed() ->select('entitleable_id', 'entitleable_type') ->distinct() ->whereIn('wallet_id', $wallets) ->get() ->each(function ($entitlement) use ($user, $force) { // Skip the current user (infinite recursion loop) if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) { return; } // Objects need to be deleted one by one to make sure observers can do the proper cleanup if ($force) { $entitlement->entitleable->forceDelete(); } elseif (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->delete(); } }); if ($force) { // Remove "wallet" transactions, they have no foreign key constraint \App\Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } + + // regardless of force delete, we're always purging whitelists... just in case + \App\Policy\RateLimitWhitelist::where( + [ + 'whitelistable_id' => $user->id, + 'whitelistable_type' => User::class + ] + )->delete(); } } diff --git a/src/app/Policy/RateLimit.php b/src/app/Policy/RateLimit.php new file mode 100644 index 00000000..7605da4b --- /dev/null +++ b/src/app/Policy/RateLimit.php @@ -0,0 +1,27 @@ +belongsTo('App\User'); + } + + public function user() + { + $this->belongsTo('App\User'); + } +} diff --git a/src/app/Policy/RateLimitWhitelist.php b/src/app/Policy/RateLimitWhitelist.php new file mode 100644 index 00000000..daa1f5f4 --- /dev/null +++ b/src/app/Policy/RateLimitWhitelist.php @@ -0,0 +1,25 @@ +morphTo(); + } +} diff --git a/src/config/app.php b/src/config/app.php index 4eeaff61..ca93105a 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,304 +1,306 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], 'headers' => [ 'csp' => env('APP_HEADER_CSP', ""), 'xfo' => env('APP_HEADER_XFO', ""), ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'storage' => [ 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"), ], 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), 'signup' => [ 'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0), 'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0), ], 'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')), 'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')), + + 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')) ]; diff --git a/src/database/migrations/2021_12_28_103243_create_policy_ratelimit_tables.php b/src/database/migrations/2021_12_28_103243_create_policy_ratelimit_tables.php new file mode 100644 index 00000000..38d65141 --- /dev/null +++ b/src/database/migrations/2021_12_28_103243_create_policy_ratelimit_tables.php @@ -0,0 +1,60 @@ +bigIncrements('id'); + $table->bigInteger('user_id'); + $table->bigInteger('owner_id'); + $table->string('recipient_hash', 128); + $table->tinyInteger('recipient_count')->unsigned(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('owner_id')->references('id')->on('users')->onDelete('cascade'); + + $table->index(['user_id', 'updated_at']); + $table->index(['owner_id', 'updated_at']); + $table->index(['user_id', 'recipient_hash', 'updated_at']); + } + ); + + Schema::create( + 'policy_ratelimit_wl', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->bigInteger('whitelistable_id'); + $table->string('whitelistable_type'); + $table->timestamps(); + + $table->index(['whitelistable_id', 'whitelistable_type']); + $table->unique(['whitelistable_id', 'whitelistable_type']); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('policy_ratelimit'); + Schema::dropIfExists('policy_ratelimit_wl'); + } +} diff --git a/src/tests/Feature/Stories/RateLimitTest.php b/src/tests/Feature/Stories/RateLimitTest.php new file mode 100644 index 00000000..fff9071a --- /dev/null +++ b/src/tests/Feature/Stories/RateLimitTest.php @@ -0,0 +1,562 @@ +setUpTest(); + $this->useServicesUrl(); + } + + public function tearDown(): void + { + parent::tearDown(); + } + + /** + * Verify an individual can send an email unrestricted, so long as the account is active. + */ + public function testIndividualDunno() + { + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => [ 'someone@test.domain' ] + ]; + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + /** + * Verify a whitelisted individual account is in fact whitelisted + */ + public function testIndividualWhitelist() + { + \App\Policy\RateLimitWhitelist::create( + [ + 'whitelistable_id' => $this->publicDomainUser->id, + 'whitelistable_type' => \App\User::class + ] + ); + + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => [] + ]; + + // first 9 requests + for ($i = 1; $i <= 9; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // normally, request #10 would get blocked + $request['recipients'] = ['0010@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(200); + + // requests 11 through 26 + for ($i = 11; $i <= 26; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + } + + /** + * Verify an individual trial user is automatically suspended. + */ + public function testIndividualAutoSuspendMessages() + { + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => [] + ]; + + // first 9 requests + for ($i = 1; $i <= 9; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // the next 16 requests for 25 total + for ($i = 10; $i <= 25; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(403); + } + + $this->assertTrue($this->publicDomainUser->fresh()->isSuspended()); + } + + /** + * Verify a suspended individual can not send an email + */ + public function testIndividualSuspended() + { + $this->publicDomainUser->suspend(); + + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => ['someone@test.domain'] + ]; + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(403); + } + + /** + * Verify an individual can run out of messages per hour + */ + public function testIndividualTrialMessages() + { + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => [] + ]; + + // first 9 requests + for ($i = 1; $i <= 9; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // the tenth request should be blocked + $request['recipients'] = ['0010@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(403); + } + + /** + * Verify a paid for individual account does not simply run out of messages + */ + public function testIndividualPaidMessages() + { + $wallet = $this->publicDomainUser->wallets()->first(); + + // Ensure there are no payments for the wallet + \App\Payment::where('wallet_id', $wallet->id)->delete(); + + $payment = [ + 'id' => \App\Utils::uuidInt(), + 'status' => \App\Providers\PaymentProvider::STATUS_PAID, + 'type' => \App\Providers\PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in March', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]; + + \App\Payment::create($payment); + $wallet->credit(1111); + + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => ['someone@test.domain'] + ]; + + // first 9 requests + for ($i = 1; $i <= 9; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // the tenth request should be blocked + $request['recipients'] = ['0010@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(403); + + // create a second payment + $payment['id'] = \App\Utils::uuidInt(); + \App\Payment::create($payment); + $wallet->credit(1111); + + // the tenth request should now be allowed + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(200); + } + + /** + * Verify that an individual user in its trial can run out of recipients. + */ + public function testIndividualTrialRecipients() + { + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => [] + ]; + + // first 2 requests (34 recipients each) + for ($x = 1; $x <= 2; $x++) { + $request['recipients'] = []; + + for ($y = 1; $y <= 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", $x * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // on to the third request, resulting in 102 recipients total + $request['recipients'] = []; + + for ($y = 1; $y <= 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", 3 * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(403); + } + + /** + * Verify that an individual user that has paid for its account doesn't run out of recipients. + */ + public function testIndividualPaidRecipients() + { + $wallet = $this->publicDomainUser->wallets()->first(); + + // Ensure there are no payments for the wallet + \App\Payment::where('wallet_id', $wallet->id)->delete(); + + $payment = [ + 'id' => \App\Utils::uuidInt(), + 'status' => \App\Providers\PaymentProvider::STATUS_PAID, + 'type' => \App\Providers\PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in March', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]; + + \App\Payment::create($payment); + $wallet->credit(1111); + + $request = [ + 'sender' => $this->publicDomainUser->email, + 'recipients' => [] + ]; + + // first 2 requests (34 recipients each) + for ($x = 0; $x < 2; $x++) { + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", $x * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // on to the third request, resulting in 102 recipients total + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", 2 * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(403); + + $payment['id'] = \App\Utils::uuidInt(); + + \App\Payment::create($payment); + $wallet->credit(1111); + + // the tenth request should now be allowed + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200, '102nd recipient not accepted'); + } + + /** + * Verify that a group owner can send email + */ + public function testGroupOwnerDunno() + { + $request = [ + 'sender' => $this->domainOwner->email, + 'recipients' => [ 'someone@test.domain' ] + ]; + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + /** + * Verify that a domain owner can run out of messages + */ + public function testGroupTrialOwnerMessages() + { + $request = [ + 'sender' => $this->domainOwner->email, + 'recipients' => [] + ]; + + // first 9 requests + for ($i = 0; $i < 9; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // the tenth request should be blocked + $request['recipients'] = ['0010@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(403); + + $this->assertFalse($this->domainOwner->fresh()->isSuspended()); + } + + /** + * Verify that a domain owner can run out of recipients + */ + public function testGroupTrialOwnerRecipients() + { + $request = [ + 'sender' => $this->domainOwner->email, + 'recipients' => [] + ]; + + // first 2 requests (34 recipients each) + for ($x = 0; $x < 2; $x++) { + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", $x * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // on to the third request, resulting in 102 recipients total + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", 2 * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(403); + + $this->assertFalse($this->domainOwner->fresh()->isSuspended()); + } + + /** + * Verify that a paid for group account can send messages. + */ + public function testGroupPaidOwnerRecipients() + { + $wallet = $this->domainOwner->wallets()->first(); + + // Ensure there are no payments for the wallet + \App\Payment::where('wallet_id', $wallet->id)->delete(); + + $payment = [ + 'id' => \App\Utils::uuidInt(), + 'status' => \App\Providers\PaymentProvider::STATUS_PAID, + 'type' => \App\Providers\PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in March', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]; + + \App\Payment::create($payment); + $wallet->credit(1111); + + $request = [ + 'sender' => $this->domainOwner->email, + 'recipients' => [] + ]; + + // first 2 requests (34 recipients each) + for ($x = 0; $x < 2; $x++) { + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", $x * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // on to the third request, resulting in 102 recipients total + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", 2 * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(403); + + // create a second payment + $payment['id'] = \App\Utils::uuidInt(); + \App\Payment::create($payment); + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(200); + } + + /** + * Verify that a user for a domain owner can send email. + */ + public function testGroupUserDunno() + { + $request = [ + 'sender' => $this->domainUsers[0]->email, + 'recipients' => [ 'someone@test.domain' ] + ]; + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + /** + * Verify that the users in a group account can be limited. + */ + public function testGroupTrialUserMessages() + { + $user = $this->domainUsers[0]; + + $request = [ + 'sender' => $user->email, + 'recipients' => [] + ]; + + // the first eight requests should be accepted + for ($i = 0; $i < 8; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(200); + } + + $request['sender'] = $this->domainUsers[1]->email; + + // the ninth request from another group user should also be accepted + $request['recipients'] = ['0009@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(200); + + // the tenth request from another group user should be rejected + $request['recipients'] = ['0010@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(403); + } + + public function testGroupTrialUserRecipients() + { + $request = [ + 'sender' => $this->domainUsers[0]->email, + 'recipients' => [] + ]; + + // first 2 requests (34 recipients each) + for ($x = 0; $x < 2; $x++) { + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", $x * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // on to the third request, resulting in 102 recipients total + $request['recipients'] = []; + + for ($y = 0; $y < 34; $y++) { + $request['recipients'][] = sprintf("%04d@test.domain", 2 * $y); + } + + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(403); + } + + /** + * Verify a whitelisted group domain is in fact whitelisted + */ + public function testGroupDomainWhitelist() + { + \App\Policy\RateLimitWhitelist::create( + [ + 'whitelistable_id' => $this->domainHosted->id, + 'whitelistable_type' => \App\Domain::class + ] + ); + + $request = [ + 'sender' => $this->domainUsers[0]->email, + 'recipients' => [] + ]; + + // first 9 requests + for ($i = 1; $i <= 9; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + + // normally, request #10 would get blocked + $request['recipients'] = ['0010@test.domain']; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + $response->assertStatus(200); + + // requests 11 through 26 + for ($i = 11; $i <= 26; $i++) { + $request['recipients'] = [sprintf("%04d@test.domain", $i)]; + $response = $this->post('api/webhooks/policy/ratelimit', $request); + + $response->assertStatus(200); + } + } +} diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index 45cdbbb0..af598b98 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,78 +1,95 @@ withoutMiddleware(ThrottleRequests::class); } /** * Set baseURL to the regular UI location */ protected static function useRegularUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url \config( [ 'app.url' => str_replace( - ['//admin.', '//reseller.'], - ['//', '//'], + ['//admin.', '//reseller.', '//services.'], + ['//', '//', '//'], \config('app.url') ) ] ); url()->forceRootUrl(config('app.url')); } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url + + // reset to base + self::useRegularUrl(); + + // then modify it \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } /** * Set baseURL to the reseller UI location */ protected static function useResellerUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url + + // reset to base + self::useRegularUrl(); + + // then modify it \config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } /** * Set baseURL to the services location */ protected static function useServicesUrl(): void { // This will set base URL for all tests in a file. + // If we wanted to access both user and admin in one test + // we can also just call post/get/whatever with full url + + // reset to base + self::useRegularUrl(); + + // then modify it \config(['app.url' => str_replace('//', '//services.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } }