Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2527823
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
93 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jan 31, 2:59 PM (1 d, 5 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426343
Default Alt Text
(93 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment