Page MenuHomePhorge

No OneTemporary

Size
93 KB
Referenced Files
None
Subscribers
None
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 <vanmeeuwen@apheleia-it.ch>
-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.<hosted-domain> NS RRs refer to ns0{1,2}.<provider-domain>
+# Set these to IP addresses you serve WOAT with.
+# Have the domain owner point _woat.<hosted-domain> NS RRs refer to ns0{1,2}.<provider-domain>
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 @@
+<?php
+
+namespace App\Console\Commands\Policy\Greylist;
+
+use Illuminate\Console\Command;
+
+class ExpungeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'policy:greylist:expunge';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Expunge old records from the policy greylist tables';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ \App\Policy\Greylist\Connect::where('updated_at', '<', \Carbon\Carbon::now()->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 @@
+<?php
+
+namespace App\Console\Commands\Policy\RateLimit;
+
+use Illuminate\Console\Command;
+
+class ExpungeCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'policy:ratelimit:expunge';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Expunge records from the policy ratelimit table';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ \App\Policy\RateLimit::where('updated_at', '<', \Carbon\Carbon::now()->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 @@
+<?php
+
+namespace App\Console\Commands\Policy\RateLimit\Whitelist;
+
+use App\Console\Command;
+
+class CreateCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'policy:ratelimit:whitelist:create {object}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Create a ratelimit whitelist entry';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $object = $this->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 @@
+<?php
+
+namespace App\Console\Commands\Policy\RateLimit\Whitelist;
+
+use App\Console\Command;
+
+class DeleteCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'policy:ratelimit:whitelist:delete {object}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Delete a policy ratelimit whitelist item';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $object = $this->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 @@
+<?php
+
+namespace App\Console\Commands\Policy\RateLimit\Whitelist;
+
+use Illuminate\Console\Command;
+
+class ReadCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'policy:ratelimit:whitelist:read {filter?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Read the ratelimit policy whitelist';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ \App\Policy\RateLimitWhitelist::each(
+ function ($item) {
+ $whitelistable = $item->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 @@
+<?php
+
+namespace App\Console\Commands\Policy;
+
+use App\Console\ObjectListCommand;
+
+class RateLimitsCommand extends ObjectListCommand
+{
+ protected $commandPrefix = 'policy';
+ protected $objectClass = \App\Policy\RateLimit::class;
+ protected $objectName = 'ratelimit';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
index 0f9f7025..bbfc6469 100644
--- a/src/app/Http/Controllers/API/V4/PolicyController.php
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -1,256 +1,436 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class PolicyController extends Controller
{
/**
* Take a greylist policy request
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function greylist()
{
$data = \request()->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 <email|namespace>
+ $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 @@
<?php
namespace App\Observers;
use App\Domain;
use Illuminate\Support\Facades\DB;
class DomainObserver
{
/**
* Handle the domain "created" event.
*
* @param \App\Domain $domain The domain.
*
* @return void
*/
public function creating(Domain $domain): void
{
$domain->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 @@
<?php
namespace App\Observers;
use App\User;
use App\Wallet;
class UserObserver
{
/**
* Handle the "creating" event.
*
* Ensure that the user is created with a random, large integer.
*
* @param \App\User $user The user being created.
*
* @return void
*/
public function creating(User $user)
{
$user->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 @@
+<?php
+
+namespace App\Policy;
+
+use Illuminate\Database\Eloquent\Model;
+
+class RateLimit extends Model
+{
+ protected $fillable = [
+ 'user_id',
+ 'owner_id',
+ 'recipient_hash',
+ 'recipient_count'
+ ];
+
+ protected $table = 'policy_ratelimit';
+
+ public function owner()
+ {
+ $this->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 @@
+<?php
+
+namespace App\Policy;
+
+use Illuminate\Database\Eloquent\Model;
+
+class RateLimitWhitelist extends Model
+{
+ protected $fillable = [
+ 'whitelistable_id',
+ 'whitelistable_type',
+ ];
+
+ protected $table = 'policy_ratelimit_wl';
+
+ /**
+ * Principally whitelistable object such as Domain, User.
+ *
+ * @return mixed
+ */
+ public function whitelistable()
+ {
+ return $this->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 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application. This value is used when the
| framework needs to place the application's name in a notification or
| any other location as required by the application or its packages.
|
*/
'name' => 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 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+// phpcs:ignore
+class CreatePolicyRatelimitTables extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'policy_ratelimit',
+ function (Blueprint $table) {
+ $table->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 @@
+<?php
+
+namespace Tests\Feature\Stories;
+
+use App\Policy\RateLimit;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+/**
+ * @group slow
+ * @group data
+ * @group ratelimit
+ */
+class RateLimitTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Routing\Middleware\ThrottleRequests;
abstract class TestCase extends BaseTestCase
{
use TestCaseTrait;
use TestCaseMeetTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// Disable throttling
$this->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'));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jan 31, 2:59 PM (1 d, 7 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426343
Default Alt Text
(93 KB)

Event Timeline