Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117904572
D3161.1775387212.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
54 KB
Referenced Files
None
Subscribers
None
D3161.1775387212.diff
View Options
diff --git a/bin/quickstart.sh b/bin/quickstart.sh
--- a/bin/quickstart.sh
+++ b/bin/quickstart.sh
@@ -36,23 +36,19 @@
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
@@ -99,6 +95,7 @@
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
--- a/docker/swoole/Dockerfile
+++ b/docker/swoole/Dockerfile
@@ -2,7 +2,7 @@
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" \
@@ -28,8 +28,9 @@
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 && \
diff --git a/extras/kolab_policy_greylist.py b/extras/kolab_policy_greylist
rename from extras/kolab_policy_greylist.py
rename to extras/kolab_policy_greylist
--- a/extras/kolab_policy_greylist.py
+++ b/extras/kolab_policy_greylist
@@ -1,4 +1,4 @@
-#!/usr/bin/python3
+#!/usr/bin/python
"""
An example implementation of a policy service.
"""
@@ -22,6 +22,8 @@
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()
@@ -54,14 +56,17 @@
)
# 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']:
@@ -69,11 +74,8 @@
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
--- /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
--- 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
rename from extras/kolab_policy_spf.py
rename to extras/kolab_policy_spf
--- a/extras/kolab_policy_spf.py
+++ b/extras/kolab_policy_spf
@@ -1,4 +1,4 @@
-#!/usr/bin/python3
+#!/usr/bin/python
"""
This is the implementation of a (postfix) MTA policy service to enforce the
Sender Policy Framework.
@@ -23,6 +23,8 @@
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()
@@ -55,14 +57,17 @@
)
# 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']:
@@ -70,11 +75,8 @@
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
--- a/src/.env.example
+++ b/src/.env.example
@@ -100,8 +100,8 @@
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
diff --git a/src/app/Console/Commands/Policy/Greylist/ExpungeCommand.php b/src/app/Console/Commands/Policy/Greylist/ExpungeCommand.php
new file mode 100644
--- /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
--- /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
--- /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
--- /dev/null
+++ b/src/app/Console/Commands/Policy/RateLimit/Whitelist/DeleteCommand.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Console\Commands\Policy\RateLimit\Whitelist;
+
+use Illuminate\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
--- /dev/null
+++ b/src/app/Console/Commands/Policy/RateLimit/Whitelist/ReadCommand.php
@@ -0,0 +1,52 @@
+<?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';
+
+ /**
+ * Create a new command instance.
+ *
+ * @return void
+ */
+ public function __construct()
+ {
+ parent::__construct();
+ }
+
+ /**
+ * 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
--- /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
--- a/src/app/Http/Controllers/API/V4/PolicyController.php
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -47,59 +47,244 @@
*/
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
+ ]
+ )->first();
+
+ 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));
+
+ //
+ // Retrieve the wallet to get to the owner
+ //
+ $wallet = $user->entitlements->first()->wallet;
+ $owner = $wallet->owner;
+
+ // wait, there is no wallet?
+ if (!$wallet) {
+ return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403);
+ }
+
+ // 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();
+
+ 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();
+ }
+
+ // excempt owners that have made their payments, or are at 100% discount
+ $payments = $wallet->payments
+ ->where('amount', '>', 0)
+ ->where('status', 'paid');
+
+ 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
+ if ($ownerRates->count() >= 25) {
+ $wallet->entitlements->each(
+ function ($entitlement) use ($owner) {
+ // older than 2 months do not deserve automatic suspension
+ if ($owner->created_at <= \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) {
+ return;
+ }
+
+ 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
+ if ($ownerRates >= 250) {
+ $wallet->entitlements->each(
+ function ($entitlement) use ($owner) {
+ // older than 2 months do not deserve automatic suspension
+ if ($owner->created_at <= \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) {
+ return;
+ }
+
+ if ($entitlement->entitleable_type == \App\Domain::class) {
+ $entitlement->entitleable->suspend();
+ }
+
+ if ($entitlement->entitleable_type == \App\User::class) {
+ $entitlement->entitleable->suspend();
+ }
+ }
+ );
+ }
- // TODO message vs. recipient limit
- if ($userRates->count() > 10) {
- // TODO
+ return response()->json($result, 403);
}
- // this is the wallet to which the account is billed
- $wallet = $user->wallet;
+ //
+ // Examine the rates at which the user is sending
+ //
+ $userRates = \App\Policy\RateLimit::where('user_id', $user->id)
+ ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour());
- // TODO: consider $wallet->payments;
+ 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
+ if ($userRates->count() >= 25) {
+ // users older than 2 months do not deserve automatic suspension
+ if ($user->created_at > \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) {
+ $user->suspend();
+ }
+ }
- $owner = $wallet->user;
+ 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.'
+ ];
- // TODO time-limit
- $ownerRates = \App\Policy\Ratelimit::where('owner_id', $owner->id);
+ // automatically suspend if 2.5 times over the original limit
+ if ($userRates >= 250) {
+ // users older than 2 months do not deserve automatic suspension
+ if ($user->created_at > \Carbon\Carbon::now()->subMonthsWithoutOverflow(2)) {
+ $user->suspend();
+ }
+ }
- // TODO message vs. recipient limit (w/ user counts)
- if ($ownerRates->count() > 10) {
- // TODO
+ return response()->json($result, 403);
}
-*/
+
+ $result = [
+ 'response' => 'DUNNO'
+ ];
+
+ return response()->json($result, 200);
}
/*
@@ -117,7 +302,7 @@
return response()->json(
[
'response' => 'DEFER_IF_PERMIT',
- 'reason' => 'Temporary error. Please try again later (' . __LINE__ . ')'
+ 'reason' => 'Temporary error. Please try again later.'
],
403
);
diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php
--- a/src/app/Observers/DomainObserver.php
+++ b/src/app/Observers/DomainObserver.php
@@ -51,6 +51,23 @@
\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.
*
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -271,5 +271,12 @@
->whereIn('object_id', $wallets)
->delete();
}
+
+ \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
--- /dev/null
+++ b/src/app/Policy/RateLimit.php
@@ -0,0 +1,51 @@
+<?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');
+ }
+
+ /**
+ * determines whether or not the account was ever paid for
+ */
+ public static function hasPaid()
+ {
+ return false;
+ }
+
+ /**
+ * determines whether or not the account is in its trial period
+ */
+ public static function isTrial()
+ {
+ return true;
+ }
+
+ /**
+ * determines whether a threshold is met
+ */
+ public static function surpassesThreshold()
+ {
+ return true;
+ }
+}
diff --git a/src/app/Policy/RateLimitWhitelist.php b/src/app/Policy/RateLimitWhitelist.php
new file mode 100644
--- /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
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -296,4 +296,6 @@
'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
--- /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');
+ $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
--- /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
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -32,8 +32,8 @@
\config(
[
'app.url' => str_replace(
- ['//admin.', '//reseller.'],
- ['//', '//'],
+ ['//admin.', '//reseller.', '//services.'],
+ ['//', '//', '//'],
\config('app.url')
)
]
@@ -50,6 +50,11 @@
// 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'));
}
@@ -62,6 +67,11 @@
// 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'));
}
@@ -72,6 +82,13 @@
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/plain
Expires
Sun, Apr 5, 11:06 AM (4 h, 57 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833262
Default Alt Text
D3161.1775387212.diff (54 KB)
Attached To
Mode
D3161: Implement Kolab 4 rate limitations
Attached
Detach File
Event Timeline