Page MenuHomePhorge

No OneTemporary

Size
83 KB
Referenced Files
None
Subscribers
None
diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php
index 91380612..ffad402d 100644
--- a/src/app/Console/Command.php
+++ b/src/app/Console/Command.php
@@ -1,208 +1,221 @@
<?php
namespace App\Console;
use Illuminate\Support\Facades\DB;
abstract class Command extends \Illuminate\Console\Command
{
/**
* This needs to be here to be used.
*
* @var null
*/
protected $commandPrefix = null;
/**
* Annotate this command as being dangerous for any potential unintended consequences.
*
* Commands are considered dangerous if;
*
* * observers are deliberately not triggered, meaning that the deletion of an object model that requires the
* associated observer to clean some things up, or charge a wallet or something, are deliberately not triggered,
*
* * deletion of objects and their relations rely on database foreign keys with obscure cascading,
*
* * a command will result in the permanent, irrecoverable loss of data.
*
* @var boolean
*/
protected $dangerous = false;
/**
* Find the domain.
*
* @param string $domain Domain ID or namespace
* @param bool $withDeleted Include deleted
*
* @return \App\Domain|null
*/
public function getDomain($domain, $withDeleted = false)
{
return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted);
}
+ /**
+ * Find a group.
+ *
+ * @param string $group Group ID or email
+ * @param bool $withDeleted Include deleted
+ *
+ * @return \App\Group|null
+ */
+ public function getGroup($group, $withDeleted = false)
+ {
+ return $this->getObject(\App\Group::class, $group, 'email', $withDeleted);
+ }
+
/**
* Find an object.
*
* @param string $objectClass The name of the class
* @param string $objectIdOrTitle The name of a database field to match.
* @param string|null $objectTitle An additional database field to match.
* @param bool $withDeleted Act as if --with-deleted was used
*
* @return mixed
*/
public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false)
{
if (!$withDeleted) {
$withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted');
}
$object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle);
if (!$object && !empty($objectTitle)) {
$object = $this->getObjectModel($objectClass, $withDeleted)
->where($objectTitle, $objectIdOrTitle)->first();
}
return $object;
}
/**
* Returns a preconfigured Model object for a specified class.
*
* @param string $objectClass The name of the class
* @param bool $withDeleted Include withTrashed() query
*
* @return mixed
*/
protected function getObjectModel($objectClass, $withDeleted = false)
{
if ($withDeleted) {
$model = $objectClass::withTrashed();
} else {
$model = new $objectClass();
}
if ($this->commandPrefix == 'scalpel') {
return $model;
}
$modelsWithTenant = [
\App\Discount::class,
\App\Domain::class,
\App\Group::class,
\App\Package::class,
\App\Plan::class,
\App\Sku::class,
\App\User::class,
];
$modelsWithOwner = [
\App\Wallet::class,
];
$tenantId = \config('app.tenant_id');
// Add tenant filter
if (in_array($objectClass, $modelsWithTenant)) {
$model = $model->withEnvTenantContext();
} elseif (in_array($objectClass, $modelsWithOwner)) {
$model = $model->whereExists(function ($query) use ($tenantId) {
$query->select(DB::raw(1))
->from('users')
->whereRaw('wallets.user_id = users.id')
->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null'));
});
}
return $model;
}
/**
* Find the user.
*
* @param string $user User ID or email
* @param bool $withDeleted Include deleted
*
* @return \App\User|null
*/
public function getUser($user, $withDeleted = false)
{
return $this->getObject(\App\User::class, $user, 'email', $withDeleted);
}
/**
* Find the wallet.
*
* @param string $wallet Wallet ID
*
* @return \App\Wallet|null
*/
public function getWallet($wallet)
{
return $this->getObject(\App\Wallet::class, $wallet, null);
}
public function handle()
{
if ($this->dangerous) {
$this->warn(
"This command is a dangerous scalpel command with potentially significant unintended consequences"
);
$confirmation = $this->confirm("Are you sure you understand what's about to happen?");
if (!$confirmation) {
$this->info("Better safe than sorry.");
return false;
}
$this->info("Vámonos!");
}
return true;
}
/**
* Return a string for output, with any additional attributes specified as well.
*
* @param mixed $entry An object
*
* @return string
*/
protected function toString($entry)
{
/**
* Haven't figured out yet, how to test if this command implements an option for additional
* attributes.
if (!in_array('attr', $this->options())) {
return $entry->{$entry->getKeyName()};
}
*/
$str = [
$entry->{$entry->getKeyName()}
];
foreach ($this->option('attr') as $attr) {
if ($attr == $entry->getKeyName()) {
$this->warn("Specifying {$attr} is not useful.");
continue;
}
if (!array_key_exists($attr, $entry->toArray())) {
$this->error("Attribute {$attr} isn't available");
continue;
}
if (is_numeric($entry->{$attr})) {
$str[] = $entry->{$attr};
} else {
$str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null";
}
}
return implode(" ", $str);
}
}
diff --git a/src/app/Console/Commands/DB/PingCommand.php b/src/app/Console/Commands/DB/PingCommand.php
index bf524bf1..c6870b9d 100644
--- a/src/app/Console/Commands/DB/PingCommand.php
+++ b/src/app/Console/Commands/DB/PingCommand.php
@@ -1,62 +1,52 @@
<?php
namespace App\Console\Commands\DB;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class PingCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:ping {--wait}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Ping the database [and wait for it to respond]';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if ($this->option('wait')) {
while (true) {
try {
$result = DB::select("SELECT 1");
if (sizeof($result) > 0) {
break;
}
} catch (\Exception $exception) {
sleep(1);
}
}
} else {
try {
$result = DB::select("SELECT 1");
return 0;
} catch (\Exception $exception) {
return 1;
}
}
}
}
diff --git a/src/app/Console/Commands/DB/VerifyTimezoneCommand.php b/src/app/Console/Commands/DB/VerifyTimezoneCommand.php
index 0a78d3ff..2ffd33dc 100644
--- a/src/app/Console/Commands/DB/VerifyTimezoneCommand.php
+++ b/src/app/Console/Commands/DB/VerifyTimezoneCommand.php
@@ -1,57 +1,47 @@
<?php
namespace App\Console\Commands\DB;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class VerifyTimezoneCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:verify-timezone';
/**
* The console command description.
*
* @var string
*/
protected $description = "Verify the application's timezone compared to the DB timezone";
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$result = \Illuminate\Support\Facades\DB::select("SHOW VARIABLES WHERE Variable_name = 'time_zone'");
$appTimezone = \config('app.timezone');
if ($appTimezone != "UTC") {
$this->error("The application timezone is not configured to be UTC");
return 1;
}
if ($result[0]->{'Value'} != '+00:00' && $result[0]->{'Value'} != 'UTC') {
$this->error("The database timezone is neither configured as '+00:00' nor 'UTC'");
return 1;
}
return 0;
}
}
diff --git a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php
index 5200fd78..c2e83e6f 100644
--- a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php
+++ b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php
@@ -1,225 +1,215 @@
<?php
namespace App\Console\Commands\Data\Import;
use App\Console\Command;
use Carbon\Carbon;
class IP4NetsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'data:import:ip4nets';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Update IP4 Networks';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$rirs = [
'afrinic' => 'http://ftp.afrinic.net/stats/afrinic/delegated-afrinic-latest',
'apnic' => 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest',
'arin' => 'http://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
'lacnic' => 'http://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-latest',
'ripencc' => 'https://ftp.ripe.net/ripe/stats/delegated-ripencc-latest'
];
$today = Carbon::now()->toDateString();
foreach ($rirs as $rir => $url) {
$file = storage_path("{$rir}-{$today}");
\App\Utils::downloadFile($url, $file);
$serial = $this->serialFromStatsFile($file);
if (!$serial) {
\Log::error("Can not derive serial from {$file}");
continue;
}
$numLines = $this->countLines($file);
if (!$numLines) {
\Log::error("No relevant lines could be found in {$file}");
continue;
}
$bar = \App\Utils::createProgressBar(
$this->output,
$numLines,
"Importing IPv4 Networks from {$file}"
);
$fp = fopen($file, 'r');
$nets = [];
while (!feof($fp)) {
$line = trim(fgets($fp));
if ($line == "") {
continue;
}
if ((int)$line) {
continue;
}
if ($line[0] == "#") {
continue;
}
$items = explode('|', $line);
if (sizeof($items) < 7) {
continue;
}
if ($items[1] == "*") {
continue;
}
if ($items[2] != "ipv4") {
continue;
}
if ($items[5] == "00000000") {
$items[5] = "19700102";
}
if ($items[1] == "" || $items[1] == "ZZ") {
continue;
}
$bar->advance();
$mask = 32 - log($items[4], 2);
$net = \App\IP4Net::where(
[
'net_number' => $items[3],
'net_mask' => $mask,
'net_broadcast' => long2ip((ip2long($items[3]) + 2 ** (32 - $mask)) - 1)
]
)->first();
if ($net) {
if ($net->updated_at > Carbon::now()->subDays(1)) {
continue;
}
// don't use ->update() method because it doesn't update updated_at which we need for expiry
$net->rir_name = $rir;
$net->country = $items[1];
$net->serial = $serial;
$net->updated_at = Carbon::now();
$net->save();
continue;
}
$nets[] = [
'rir_name' => $rir,
'net_number' => $items[3],
'net_mask' => $mask,
'net_broadcast' => long2ip((ip2long($items[3]) + 2 ** (32 - $mask)) - 1),
'country' => $items[1],
'serial' => $serial,
'created_at' => Carbon::parse($items[5], 'UTC'),
'updated_at' => Carbon::now()
];
if (sizeof($nets) >= 100) {
\App\IP4Net::insert($nets);
$nets = [];
}
}
if (sizeof($nets) > 0) {
\App\IP4Net::insert($nets);
$nets = [];
}
$bar->finish();
$this->info("DONE");
}
return 0;
}
private function countLines($file)
{
$numLines = 0;
$fh = fopen($file, 'r');
while (!feof($fh)) {
$line = trim(fgets($fh));
$items = explode('|', $line);
if (sizeof($items) < 3) {
continue;
}
if ($items[2] == "ipv4") {
$numLines++;
}
}
fclose($fh);
return $numLines;
}
private function serialFromStatsFile($file)
{
$serial = null;
$fh = fopen($file, 'r');
while (!feof($fh)) {
$line = trim(fgets($fh));
$items = explode('|', $line);
if (sizeof($items) < 2) {
continue;
}
if ((int)$items[2]) {
$serial = (int)$items[2];
break;
}
}
fclose($fh);
return $serial;
}
}
diff --git a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php
index afb0c67a..b71e532b 100644
--- a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php
+++ b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php
@@ -1,223 +1,213 @@
<?php
namespace App\Console\Commands\Data\Import;
use App\Console\Command;
use Carbon\Carbon;
class IP6NetsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'data:import:ip6nets';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Import IP6 Networks.';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$rirs = [
'afrinic' => 'http://ftp.afrinic.net/stats/afrinic/delegated-afrinic-latest',
'apnic' => 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest',
'arin' => 'http://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
'lacnic' => 'http://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-latest',
'ripencc' => 'https://ftp.ripe.net/ripe/stats/delegated-ripencc-latest'
];
$today = Carbon::now()->toDateString();
foreach ($rirs as $rir => $url) {
$file = storage_path("{$rir}-{$today}");
\App\Utils::downloadFile($url, $file);
$serial = $this->serialFromStatsFile($file);
if (!$serial) {
\Log::error("Can not derive serial from {$file}");
continue;
}
$numLines = $this->countLines($file);
if (!$numLines) {
\Log::error("No relevant lines could be found in {$file}");
continue;
}
$bar = \App\Utils::createProgressBar(
$this->output,
$numLines,
"Importing IPv6 Networks from {$file}"
);
$fp = fopen($file, 'r');
$nets = [];
while (!feof($fp)) {
$line = trim(fgets($fp));
if ($line == "") {
continue;
}
if ((int)$line) {
continue;
}
if ($line[0] == "#") {
continue;
}
$items = explode('|', $line);
if (sizeof($items) < 7) {
continue;
}
if ($items[1] == "*") {
continue;
}
if ($items[2] != "ipv6") {
continue;
}
if ($items[5] == "00000000") {
$items[5] = "19700102";
}
if ($items[1] == "" || $items[1] == "ZZ") {
continue;
}
$bar->advance();
$broadcast = \App\Utils::ip6Broadcast($items[3], (int)$items[4]);
$net = \App\IP6Net::where(
[
'net_number' => $items[3],
'net_mask' => (int)$items[4],
'net_broadcast' => $broadcast
]
)->first();
if ($net) {
if ($net->updated_at > Carbon::now()->subDays(1)) {
continue;
}
// don't use ->update() method because it doesn't update updated_at which we need for expiry
$net->rir_name = $rir;
$net->country = $items[1];
$net->serial = $serial;
$net->updated_at = Carbon::now();
$net->save();
continue;
}
$nets[] = [
'rir_name' => $rir,
'net_number' => $items[3],
'net_mask' => (int)$items[4],
'net_broadcast' => $broadcast,
'country' => $items[1],
'serial' => $serial,
'created_at' => Carbon::parse($items[5], 'UTC'),
'updated_at' => Carbon::now()
];
if (sizeof($nets) >= 100) {
\App\IP6Net::insert($nets);
$nets = [];
}
}
if (sizeof($nets) > 0) {
\App\IP6Net::insert($nets);
$nets = [];
}
$bar->finish();
$this->info("DONE");
}
}
private function countLines($file)
{
$numLines = 0;
$fh = fopen($file, 'r');
while (!feof($fh)) {
$line = trim(fgets($fh));
$items = explode('|', $line);
if (sizeof($items) < 3) {
continue;
}
if ($items[2] == "ipv6") {
$numLines++;
}
}
fclose($fh);
return $numLines;
}
private function serialFromStatsFile($file)
{
$serial = null;
$fh = fopen($file, 'r');
while (!feof($fh)) {
$line = trim(fgets($fh));
$items = explode('|', $line);
if (sizeof($items) < 2) {
continue;
}
if ((int)$items[2]) {
$serial = (int)$items[2];
break;
}
}
fclose($fh);
return $serial;
}
}
diff --git a/src/app/Console/Commands/Data/ImportCommand.php b/src/app/Console/Commands/Data/ImportCommand.php
index 1fed8ab5..3c24ee9f 100644
--- a/src/app/Console/Commands/Data/ImportCommand.php
+++ b/src/app/Console/Commands/Data/ImportCommand.php
@@ -1,55 +1,45 @@
<?php
namespace App\Console\Commands\Data;
use Illuminate\Console\Command;
class ImportCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'data:import';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$commands = [
#Import\CountriesCommand::class,
Import\OpenExchangeRatesCommand::class,
#Import\IP4NetsCommand::class,
#Import\IP6NetsCommand::class
];
foreach ($commands as $command) {
$execution = new $command();
$execution->output = $this->output;
$execution->handle();
}
return 0;
}
}
diff --git a/src/app/Console/Commands/Group/AddMemberCommand.php b/src/app/Console/Commands/Group/AddMemberCommand.php
index 2e2255ca..6d30ad72 100644
--- a/src/app/Console/Commands/Group/AddMemberCommand.php
+++ b/src/app/Console/Commands/Group/AddMemberCommand.php
@@ -1,56 +1,56 @@
<?php
namespace App\Console\Commands\Group;
use App\Console\Command;
use App\Http\Controllers\API\V4\GroupsController;
class AddMemberCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'group:add-member {group} {member}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Add a member to a group.";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$input = $this->argument('group');
$member = \strtolower($this->argument('member'));
- $group = $this->getObject(\App\Group::class, $input, 'email');
+ $group = $this->getGroup($input);
if (empty($group)) {
$this->error("Group {$input} does not exist.");
return 1;
}
if (in_array($member, $group->members)) {
$this->error("{$member}: Already exists in the group.");
return 1;
}
$owner = $group->wallet()->owner;
if ($error = GroupsController::validateMemberEmail($member, $owner)) {
$this->error("{$member}: $error");
return 1;
}
// We can't modify the property indirectly, therefor array_merge()
$group->members = array_merge($group->members, [$member]);
$group->save();
}
}
diff --git a/src/app/Console/Commands/Group/DeleteCommand.php b/src/app/Console/Commands/Group/DeleteCommand.php
index 9f524723..df3c66e9 100644
--- a/src/app/Console/Commands/Group/DeleteCommand.php
+++ b/src/app/Console/Commands/Group/DeleteCommand.php
@@ -1,40 +1,40 @@
<?php
namespace App\Console\Commands\Group;
use App\Console\Command;
class DeleteCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'group:delete {group}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Delete a group.";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$input = $this->argument('group');
- $group = $this->getObject(\App\Group::class, $input, 'email');
+ $group = $this->getGroup($input);
if (empty($group)) {
$this->error("Group {$input} does not exist.");
return 1;
}
$group->delete();
}
}
diff --git a/src/app/Console/Commands/Group/ForceDeleteCommand.php b/src/app/Console/Commands/Group/ForceDeleteCommand.php
new file mode 100644
index 00000000..27024338
--- /dev/null
+++ b/src/app/Console/Commands/Group/ForceDeleteCommand.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class ForceDeleteCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:force-delete {group}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Delete a group for realz';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $group = $this->getGroup($this->argument('group'), true);
+
+ if (!$group) {
+ $this->error("Group not found.");
+ return 1;
+ }
+
+ if (!$group->trashed()) {
+ $this->error("The group is not yet deleted.");
+ return 1;
+ }
+
+ DB::beginTransaction();
+ $group->forceDelete();
+ DB::commit();
+ }
+}
diff --git a/src/app/Console/Commands/Group/InfoCommand.php b/src/app/Console/Commands/Group/InfoCommand.php
index 1b2def12..a1f74232 100644
--- a/src/app/Console/Commands/Group/InfoCommand.php
+++ b/src/app/Console/Commands/Group/InfoCommand.php
@@ -1,48 +1,48 @@
<?php
namespace App\Console\Commands\Group;
use App\Console\Command;
class InfoCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'group:info {group}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Print a group information.";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$input = $this->argument('group');
- $group = $this->getObject(\App\Group::class, $input, 'email');
+ $group = $this->getGroup($input);
if (empty($group)) {
$this->error("Group {$input} does not exist.");
return 1;
}
$this->info('Id: ' . $group->id);
$this->info('Email: ' . $group->email);
$this->info('Status: ' . $group->status);
// TODO: Print owner/wallet
foreach ($group->members as $member) {
$this->info('Member: ' . $member);
}
}
}
diff --git a/src/app/Console/Commands/Group/RemoveMemberCommand.php b/src/app/Console/Commands/Group/RemoveMemberCommand.php
index 341f9e9c..377f168b 100644
--- a/src/app/Console/Commands/Group/RemoveMemberCommand.php
+++ b/src/app/Console/Commands/Group/RemoveMemberCommand.php
@@ -1,56 +1,55 @@
<?php
namespace App\Console\Commands\Group;
use App\Console\Command;
class RemoveMemberCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'group:remove-member {group} {member}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Remove a member from a group.";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$input = $this->argument('group');
$member = \strtolower($this->argument('member'));
-
- $group = $this->getObject(\App\Group::class, $input, 'email');
+ $group = $this->getGroup($input);
if (empty($group)) {
$this->error("Group {$input} does not exist.");
return 1;
}
$members = [];
foreach ($group->members as $m) {
if ($m !== $member) {
$members[] = $m;
}
}
if (count($members) == count($group->members)) {
$this->error("Member {$member} not found in the group.");
return 1;
}
$group->members = $members;
$group->save();
}
}
diff --git a/src/app/Console/Commands/Group/RestoreCommand.php b/src/app/Console/Commands/Group/RestoreCommand.php
new file mode 100644
index 00000000..aed6267b
--- /dev/null
+++ b/src/app/Console/Commands/Group/RestoreCommand.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class RestoreCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:restore {group}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Restore (undelete) a group';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $group = $this->getGroup($this->argument('group'), true);
+
+ if (!$group) {
+ $this->error("Group not found.");
+ return 1;
+ }
+
+ if (!$group->trashed()) {
+ $this->error("The group is not deleted.");
+ return 1;
+ }
+
+ DB::beginTransaction();
+ $group->restore();
+ DB::commit();
+ }
+}
diff --git a/src/app/Console/Commands/PlanPackages.php b/src/app/Console/Commands/PlanPackages.php
index 086dcf5a..9384cea2 100644
--- a/src/app/Console/Commands/PlanPackages.php
+++ b/src/app/Console/Commands/PlanPackages.php
@@ -1,87 +1,77 @@
<?php
namespace App\Console\Commands;
use App\Plan;
use Illuminate\Console\Command;
class PlanPackages extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'plan:packages';
/**
* The console command description.
*
* @var string
*/
protected $description = "List packages for plans.";
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$plans = Plan::withEnvTenantContext()->get();
foreach ($plans as $plan) {
$this->info(sprintf("Plan: %s", $plan->title));
$plan_costs = 0;
foreach ($plan->packages as $package) {
$qtyMin = $package->pivot->qty_min;
$qtyMax = $package->pivot->qty_max;
$discountQty = $package->pivot->discount_qty;
$discountRate = (100 - $package->pivot->discount_rate) / 100;
$this->info(
sprintf(
" Package: %s (min: %d, max: %d, discount %d%% after the first %d, base cost: %d)",
$package->title,
$package->pivot->qty_min,
$package->pivot->qty_max,
$package->pivot->discount_rate,
$package->pivot->discount_qty,
$package->cost()
)
);
foreach ($package->skus as $sku) {
$this->info(sprintf(" SKU: %s (%d)", $sku->title, $sku->pivot->qty));
}
if ($qtyMin < $discountQty) {
$plan_costs += $qtyMin * $package->cost();
} elseif ($qtyMin == $discountQty) {
$plan_costs += $package->cost();
} else {
// base rate
$plan_costs += $discountQty * $package->cost();
// discounted rate
$plan_costs += ($qtyMin - $discountQty) * $package->cost() * $discountRate;
}
}
$this->info(sprintf(" Plan costs per month: %d", $plan_costs));
}
}
}
diff --git a/src/app/Console/Commands/PowerDNS/Domain/CreateCommand.php b/src/app/Console/Commands/PowerDNS/Domain/CreateCommand.php
index 246720c4..b289c963 100644
--- a/src/app/Console/Commands/PowerDNS/Domain/CreateCommand.php
+++ b/src/app/Console/Commands/PowerDNS/Domain/CreateCommand.php
@@ -1,61 +1,51 @@
<?php
namespace App\Console\Commands\PowerDNS\Domain;
use Illuminate\Console\Command;
class CreateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'powerdns:domain:create {domain}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a domain in PowerDNS';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$name = $this->argument('domain');
if (substr($name, -1) == '.') {
\Log::error("Domain can not end in '.'");
return 1;
}
if (substr_count($name, '.') < 1) {
\Log::error("Invalid syntax for a domain.");
return 1;
}
$domain = \App\PowerDNS\Domain::where('name', $name)->first();
if ($domain) {
\Log::error("Domain already exists");
return 1;
}
\App\PowerDNS\Domain::create(['name' => $name]);
}
}
diff --git a/src/app/Console/Commands/PowerDNS/Domain/DeleteCommand.php b/src/app/Console/Commands/PowerDNS/Domain/DeleteCommand.php
index e5352560..94cce62b 100644
--- a/src/app/Console/Commands/PowerDNS/Domain/DeleteCommand.php
+++ b/src/app/Console/Commands/PowerDNS/Domain/DeleteCommand.php
@@ -1,50 +1,40 @@
<?php
namespace App\Console\Commands\PowerDNS\Domain;
use Illuminate\Console\Command;
class DeleteCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'powerdns:domain:delete {domain}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a PowerDNS domain';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$name = $this->argument('domain');
$domain = \App\PowerDNS\Domain::where('name', $name)->first();
if (!$domain) {
return 1;
}
$domain->delete();
}
}
diff --git a/src/app/Console/Commands/PowerDNS/Domain/ReadCommand.php b/src/app/Console/Commands/PowerDNS/Domain/ReadCommand.php
index 080db67b..ba25ca98 100644
--- a/src/app/Console/Commands/PowerDNS/Domain/ReadCommand.php
+++ b/src/app/Console/Commands/PowerDNS/Domain/ReadCommand.php
@@ -1,52 +1,42 @@
<?php
namespace App\Console\Commands\PowerDNS\Domain;
use Illuminate\Console\Command;
class ReadCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'powerdns:domain:read {domain}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Read a PowerDNS domain';
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$name = $this->argument('domain');
$domain = \App\PowerDNS\Domain::where('name', $name)->first();
if (!$domain) {
return 1;
}
foreach ($domain->records as $record) {
$this->info($record->toString());
}
}
}
diff --git a/src/app/Console/Development/DomainStatus.php b/src/app/Console/Development/DomainStatus.php
index 84be982c..47c26b65 100644
--- a/src/app/Console/Development/DomainStatus.php
+++ b/src/app/Console/Development/DomainStatus.php
@@ -1,87 +1,77 @@
<?php
namespace App\Console\Development;
use App\Domain;
use Illuminate\Console\Command;
class DomainStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'domain:status {domain} {--add=} {--del=}';
/**
* The console command description.
*
* @var string
*/
protected $description = "Set/get a domain's status.";
- /**
- * Create a new command instance.
- *
- * @return void
- */
- public function __construct()
- {
- parent::__construct();
- }
-
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$domain = Domain::where('namespace', $this->argument('domain'))->firstOrFail();
$this->info("Found domain: {$domain->id}");
$statuses = [
'active' => Domain::STATUS_ACTIVE,
'suspended' => Domain::STATUS_SUSPENDED,
'deleted' => Domain::STATUS_DELETED,
'ldapReady' => Domain::STATUS_LDAP_READY,
'verified' => Domain::STATUS_VERIFIED,
'confirmed' => Domain::STATUS_CONFIRMED,
];
// I'd prefer "-state" and "+state" syntax, but it's not possible
$delete = false;
if ($update = $this->option('del')) {
$delete = true;
} elseif ($update = $this->option('add')) {
// do nothing
}
if (!empty($update)) {
$map = \array_change_key_case($statuses);
$update = \strtolower($update);
if (isset($map[$update])) {
if ($delete && $domain->status & $map[$update]) {
$domain->status ^= $map[$update];
$domain->save();
} elseif (!$delete && !($domain->status & $map[$update])) {
$domain->status |= $map[$update];
$domain->save();
}
}
}
$domain_state = [];
foreach (\array_keys($statuses) as $state) {
$func = 'is' . \ucfirst($state);
if ($domain->$func()) {
$domain_state[] = $state;
}
}
$this->info("Status: " . \implode(',', $domain_state));
}
}
diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php
index 22057172..48ee81bf 100644
--- a/src/app/Entitlement.php
+++ b/src/app/Entitlement.php
@@ -1,175 +1,201 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\UuidStrKeyTrait;
/**
* The eloquent definition of an Entitlement.
*
* Owned by a {@link \App\User}, billed to a {@link \App\Wallet}.
*
* @property int $cost
* @property ?string $description
* @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement).
* @property int $entitleable_id
* @property string $entitleable_type
* @property int $fee
* @property string $id
* @property \App\User $owner The owner of this entitlement (subject).
* @property \App\Sku $sku The SKU to which this entitlement applies.
* @property string $sku_id
* @property \App\Wallet $wallet The wallet to which this entitlement is charged.
* @property string $wallet_id
*/
class Entitlement extends Model
{
use SoftDeletes;
use UuidStrKeyTrait;
/**
* The fillable columns for this Entitlement
*
* @var array
*/
protected $fillable = [
'sku_id',
'wallet_id',
'entitleable_id',
'entitleable_type',
'cost',
'description',
'fee',
];
protected $casts = [
'cost' => 'integer',
'fee' => 'integer'
];
/**
* Return the costs per day for this entitlement.
*
* @return float
*/
public function costsPerDay()
{
if ($this->cost == 0) {
return (float) 0;
}
$discount = $this->wallet->getDiscountRate();
$daysInLastMonth = \App\Utils::daysInLastMonth();
$costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth;
return $costsPerDay;
}
/**
* Create a transaction record for this entitlement.
*
* @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the
* \App\Transaction constants.
* @param int $amount The amount involved in cents
*
* @return string The transaction ID
*/
public function createTransaction($type, $amount = null)
{
$transaction = \App\Transaction::create(
[
'object_id' => $this->id,
'object_type' => \App\Entitlement::class,
'type' => $type,
'amount' => $amount
]
);
return $transaction->id;
}
/**
* Principally entitleable object such as Domain, User, Group.
* Note that it may be trashed (soft-deleted).
*
* @return mixed
*/
public function entitleable()
{
return $this->morphTo()->withTrashed(); // @phpstan-ignore-line
}
/**
* Returns entitleable object title (e.g. email or domain name).
*
* @return string|null An object title/name
*/
public function entitleableTitle(): ?string
{
if ($this->entitleable instanceof \App\Domain) {
return $this->entitleable->namespace;
}
return $this->entitleable->email;
}
/**
* Simplified Entitlement/SKU information for a specified entitleable object
*
* @param object $object Entitleable object
*
* @return array Skus list with some metadata
*/
public static function objectEntitlementsSummary($object): array
{
$skus = [];
// TODO: I agree this format may need to be extended in future
foreach ($object->entitlements as $ent) {
$sku = $ent->sku;
if (!isset($skus[$sku->id])) {
$skus[$sku->id] = ['costs' => [], 'count' => 0];
}
$skus[$sku->id]['count']++;
$skus[$sku->id]['costs'][] = $ent->cost;
}
return $skus;
}
+ /**
+ * Restore object entitlements.
+ *
+ * @param \App\User|\App\Domain|\App\Group $object The user|domain|group object
+ */
+ public static function restoreEntitlementsFor($object): void
+ {
+ // We'll restore only these that were deleted last. So, first we get
+ // the maximum deleted_at timestamp and then use it to select
+ // entitlements for restore
+ $deleted_at = $object->entitlements()->withTrashed()->max('deleted_at');
+
+ if ($deleted_at) {
+ $threshold = (new \Carbon\Carbon($deleted_at))->subMinute();
+
+ // Restore object entitlements
+ $object->entitlements()->withTrashed()
+ ->where('deleted_at', '>=', $threshold)
+ ->update(['updated_at' => now(), 'deleted_at' => null]);
+
+ // Note: We're assuming that cost of entitlements was correct
+ // on deletion, so we don't have to re-calculate it again.
+ // TODO: We should probably re-calculate the cost
+ }
+ }
+
/**
* The SKU concerned.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function sku()
{
return $this->belongsTo('App\Sku');
}
/**
* The wallet this entitlement is being billed to
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function wallet()
{
return $this->belongsTo('App\Wallet');
}
/**
* Cost mutator. Make sure cost is integer.
*/
public function setCostAttribute($cost): void
{
$this->attributes['cost'] = round($cost);
}
}
diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php
index 86022cc0..5f4664de 100644
--- a/src/app/Observers/DomainObserver.php
+++ b/src/app/Observers/DomainObserver.php
@@ -1,149 +1,135 @@
<?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 "deleting" event.
*
* @param \App\Domain $domain The domain.
*
* @return void
*/
public function deleting(Domain $domain)
{
// Entitlements do not have referential integrity on the entitled object, so this is our
// way of doing an onDelete('cascade') without the foreign key.
\App\Entitlement::where('entitleable_id', $domain->id)
->where('entitleable_type', Domain::class)
->delete();
}
/**
* 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 "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)
{
// Restore domain entitlements
- // We'll restore only these that were deleted last. So, first we get
- // the maximum deleted_at timestamp and then use it to select
- // domain entitlements for restore
- $deleted_at = \App\Entitlement::withTrashed()
- ->where('entitleable_id', $domain->id)
- ->where('entitleable_type', Domain::class)
- ->max('deleted_at');
-
- if ($deleted_at) {
- \App\Entitlement::withTrashed()
- ->where('entitleable_id', $domain->id)
- ->where('entitleable_type', Domain::class)
- ->where('deleted_at', '>=', (new \Carbon\Carbon($deleted_at))->subMinute())
- ->update(['updated_at' => now(), 'deleted_at' => null]);
- }
+ \App\Entitlement::restoreEntitlementsFor($domain);
// Create the domain in LDAP again
\App\Jobs\Domain\CreateJob::dispatch($domain->id);
}
/**
* Handle the domain "force deleted" event.
*
* @param \App\Domain $domain The domain.
*
* @return void
*/
public function forceDeleted(Domain $domain)
{
//
}
}
diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php
index ebb8a0ce..7c5c9481 100644
--- a/src/app/Observers/GroupObserver.php
+++ b/src/app/Observers/GroupObserver.php
@@ -1,105 +1,133 @@
<?php
namespace App\Observers;
use App\Group;
use Illuminate\Support\Facades\DB;
class GroupObserver
{
/**
* Handle the group "created" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function creating(Group $group): void
{
$group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE;
}
/**
* Handle the group "created" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function created(Group $group)
{
\App\Jobs\Group\CreateJob::dispatch($group->id);
}
/**
* Handle the group "deleting" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function deleting(Group $group)
{
// Entitlements do not have referential integrity on the entitled object, so this is our
// way of doing an onDelete('cascade') without the foreign key.
\App\Entitlement::where('entitleable_id', $group->id)
->where('entitleable_type', Group::class)
->delete();
}
/**
* Handle the group "deleted" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function deleted(Group $group)
{
if ($group->isForceDeleting()) {
return;
}
\App\Jobs\Group\DeleteJob::dispatch($group->id);
}
/**
* Handle the group "updated" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function updated(Group $group)
{
\App\Jobs\Group\UpdateJob::dispatch($group->id);
}
+ /**
+ * Handle the group "restoring" event.
+ *
+ * @param \App\Group $group The group
+ *
+ * @return void
+ */
+ public function restoring(Group $group)
+ {
+ // Make sure it's not DELETED/LDAP_READY/SUSPENDED anymore
+ if ($group->isDeleted()) {
+ $group->status ^= Group::STATUS_DELETED;
+ }
+ if ($group->isLdapReady()) {
+ $group->status ^= Group::STATUS_LDAP_READY;
+ }
+ if ($group->isSuspended()) {
+ $group->status ^= Group::STATUS_SUSPENDED;
+ }
+
+ $group->status |= Group::STATUS_ACTIVE;
+
+ // Note: $group->save() is invoked between 'restoring' and 'restored' events
+ }
+
/**
* Handle the group "restored" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function restored(Group $group)
{
- //
+ // Restore group entitlements
+ \App\Entitlement::restoreEntitlementsFor($group);
+
+ \App\Jobs\Group\CreateJob::dispatch($group->id);
}
/**
* Handle the group "force deleting" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function forceDeleted(Group $group)
{
// A group can be force-deleted separately from the owner
// we have to force-delete entitlements
\App\Entitlement::where('entitleable_id', $group->id)
->where('entitleable_type', Group::class)
->forceDelete();
}
}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
index 9c846442..5b459fba 100644
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -1,370 +1,348 @@
<?php
namespace App\Observers;
use App\Entitlement;
use App\Domain;
use App\Group;
use App\Transaction;
use App\User;
use App\Wallet;
use Illuminate\Support\Facades\DB;
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();
}
});
}
// 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 "deleting" event.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function deleting(User $user)
{
if ($user->isForceDeleting()) {
$this->forceDeleting($user);
return;
}
// TODO: Especially in tests we're doing delete() on a already deleted user.
// Should we escape here - for performance reasons?
// TODO: I think all of this should use database transactions
// Entitlements do not have referential integrity on the entitled object, so this is our
// way of doing an onDelete('cascade') without the foreign key.
$entitlements = Entitlement::where('entitleable_id', $user->id)
->where('entitleable_type', User::class)->get();
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
// Remove owned users/domains
$wallets = $user->wallets()->pluck('id')->all();
$assignments = Entitlement::whereIn('wallet_id', $wallets)->get();
$users = [];
$domains = [];
$groups = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
if ($entitlement->entitleable_type == Domain::class) {
$domains[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) {
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
}
// Domains/users/entitlements need to be deleted one by one to make sure
// events are fired and observers can do the proper cleanup.
if (!empty($users)) {
foreach (User::whereIn('id', array_unique($users))->get() as $_user) {
$_user->delete();
}
}
if (!empty($domains)) {
foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) {
$_domain->delete();
}
}
if (!empty($groups)) {
foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) {
$_group->delete();
}
}
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
// FIXME: What do we do with user wallets?
\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);
}
}
/**
* Handle the "deleting" event on forceDelete() call.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function forceDeleting(User $user)
{
// TODO: We assume that at this moment all belongings are already soft-deleted.
// Remove owned users/domains
$wallets = $user->wallets()->pluck('id')->all();
$assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get();
$entitlements = [];
$domains = [];
$groups = [];
$users = [];
foreach ($assignments as $entitlement) {
$entitlements[] = $entitlement->id;
if ($entitlement->entitleable_type == Domain::class) {
$domains[] = $entitlement->entitleable_id;
} elseif (
$entitlement->entitleable_type == User::class
&& $entitlement->entitleable_id != $user->id
) {
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
}
}
// Remove the user "direct" entitlements explicitely, if they belong to another
// user's wallet they will not be removed by the wallets foreign key cascade
Entitlement::withTrashed()
->where('entitleable_id', $user->id)
->where('entitleable_type', User::class)
->forceDelete();
// Users need to be deleted one by one to make sure observers can do the proper cleanup.
if (!empty($users)) {
foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) {
$_user->forceDelete();
}
}
// Domains can be just removed
if (!empty($domains)) {
Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete();
}
// Groups can be just removed
if (!empty($groups)) {
Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete();
}
// Remove transactions, they also have no foreign key constraint
Transaction::where('object_type', Entitlement::class)
->whereIn('object_id', $entitlements)
->delete();
Transaction::where('object_type', Wallet::class)
->whereIn('object_id', $wallets)
->delete();
}
/**
* 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)
{
- $wallets = $user->wallets()->pluck('id')->all();
-
// Restore user entitlements
- // We'll restore only these that were deleted last. So, first we get
- // the maximum deleted_at timestamp and then use it to select
- // entitlements for restore
- $deleted_at = \App\Entitlement::withTrashed()
- ->where('entitleable_id', $user->id)
- ->where('entitleable_type', User::class)
- ->max('deleted_at');
-
- if ($deleted_at) {
- $threshold = (new \Carbon\Carbon($deleted_at))->subMinute();
-
- // 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();
- }
-
- // Restore user entitlements
- \App\Entitlement::withTrashed()
- ->where('entitleable_id', $user->id)
- ->where('entitleable_type', User::class)
- ->where('deleted_at', '>=', $threshold)
- ->update(['updated_at' => now(), 'deleted_at' => null]);
-
- // Note: We're assuming that cost of entitlements was correct
- // on user deletion, so we don't have to re-calculate it again.
+ \App\Entitlement::restoreEntitlementsFor($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 "retrieving" event.
*
* @param User $user The user that is being retrieved.
*
* @todo This is useful for audit.
*
* @return void
*/
public function retrieving(User $user)
{
// TODO \App\Jobs\User\ReadJob::dispatch($user->id);
}
/**
* Handle the "updating" event.
*
* @param User $user The user that is being updated.
*
* @return void
*/
public function updating(User $user)
{
\App\Jobs\User\UpdateJob::dispatch($user->id);
}
}
diff --git a/src/tests/Feature/Console/Group/ForceDeleteTest.php b/src/tests/Feature/Console/Group/ForceDeleteTest.php
new file mode 100644
index 00000000..051fc4e4
--- /dev/null
+++ b/src/tests/Feature/Console/Group/ForceDeleteTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ForceDeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestGroup('group-test@kolabnow.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestGroup('group-test@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Warning: We're not using artisan() here, as this will not
+ // allow us to test "empty output" cases
+
+ $group = $this->getTestGroup('group-test@kolabnow.com');
+
+ // Non-existing group
+ $code = \Artisan::call("group:force-delete test@group.com");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(1, $code);
+ $this->assertSame("Group not found.", $output);
+
+ // Non-deleted group
+ $code = \Artisan::call("group:force-delete {$group->email}");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(1, $code);
+ $this->assertSame("The group is not yet deleted.", $output);
+
+ $group->delete();
+ $this->assertTrue($group->trashed());
+
+ // Existing and deleted group
+ $code = \Artisan::call("group:force-delete {$group->email}");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(0, $code);
+ $this->assertSame('', $output);
+ $this->assertCount(
+ 0,
+ Group::withTrashed()->where('email', $group->email)->get()
+ );
+ }
+}
diff --git a/src/tests/Feature/Console/Group/RestoreTest.php b/src/tests/Feature/Console/Group/RestoreTest.php
new file mode 100644
index 00000000..cd0ec565
--- /dev/null
+++ b/src/tests/Feature/Console/Group/RestoreTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class RestoreTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestGroup('group-test@kolabnow.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestGroup('group-test@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test command runs
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Warning: We're not using artisan() here, as this will not
+ // allow us to test "empty output" cases
+
+ $group = $this->getTestGroup('group-test@kolabnow.com');
+
+ // Non-existing group
+ $code = \Artisan::call("group:restore test@group.com");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(1, $code);
+ $this->assertSame("Group not found.", $output);
+
+ // Non-deleted group
+ $code = \Artisan::call("group:restore {$group->email}");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(1, $code);
+ $this->assertSame("The group is not deleted.", $output);
+
+ $group->delete();
+ $this->assertTrue($group->trashed());
+
+ // Existing and deleted group
+ $code = \Artisan::call("group:restore {$group->email}");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(0, $code);
+ $this->assertSame('', $output);
+ $this->assertFalse($group->fresh()->trashed());
+ }
+}
diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php
index da848d86..7786d7da 100644
--- a/src/tests/Feature/GroupTest.php
+++ b/src/tests/Feature/GroupTest.php
@@ -1,348 +1,398 @@
<?php
namespace Tests\Feature;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolabnow.com');
}
public function tearDown(): void
{
$this->deleteTestUser('user-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolabnow.com');
parent::tearDown();
}
/**
* Tests for Group::assignToWallet()
*/
public function testAssignToWallet(): void
{
$user = $this->getTestUser('user-test@kolabnow.com');
$group = $this->getTestGroup('group-test@kolabnow.com');
$result = $group->assignToWallet($user->wallets->first());
$this->assertSame($group, $result);
$this->assertSame(1, $group->entitlements()->count());
// Can't be done twice on the same group
$this->expectException(\Exception::class);
$result->assignToWallet($user->wallets->first());
}
/**
* Test Group::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->setSetting('sender_policy', '["test","-"]');
$this->assertSame(['sender_policy' => ['test']], $group->getConfig());
$result = $group->setConfig(['sender_policy' => [], 'unknown' => false]);
$this->assertSame(['sender_policy' => []], $group->getConfig());
$this->assertSame('[]', $group->getSetting('sender_policy'));
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$result = $group->setConfig(['sender_policy' => ['test']]);
$this->assertSame(['sender_policy' => ['test']], $group->getConfig());
$this->assertSame('["test","-"]', $group->getSetting('sender_policy'));
$this->assertSame([], $result);
}
/**
* Test group status assignment and is*() methods
*/
public function testStatus(): void
{
$group = new Group();
$this->assertSame(false, $group->isNew());
$this->assertSame(false, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(false, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status = Group::STATUS_NEW;
$this->assertSame(true, $group->isNew());
$this->assertSame(false, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(false, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_ACTIVE;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(false, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_LDAP_READY;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(true, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_DELETED;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(true, $group->isDeleted());
$this->assertSame(true, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_SUSPENDED;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(true, $group->isDeleted());
$this->assertSame(true, $group->isLdapReady());
$this->assertSame(true, $group->isSuspended());
// Unknown status value
$this->expectException(\Exception::class);
$group->status = 111;
}
/**
* Test creating a group
*/
public function testCreate(): void
{
Queue::fake();
$group = Group::create(['email' => 'GROUP-test@kolabnow.com']);
$this->assertSame('group-test@kolabnow.com', $group->email);
$this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $group->id);
$this->assertSame([], $group->members);
$this->assertTrue($group->isNew());
$this->assertTrue($group->isActive());
Queue::assertPushed(
\App\Jobs\Group\CreateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Test group deletion and force-deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@kolabnow.com');
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->assignToWallet($user->wallets->first());
$entitlements = \App\Entitlement::where('entitleable_id', $group->id);
$this->assertSame(1, $entitlements->count());
$group->delete();
$this->assertTrue($group->fresh()->trashed());
$this->assertSame(0, $entitlements->count());
$this->assertSame(1, $entitlements->withTrashed()->count());
$group->forceDelete();
$this->assertSame(0, $entitlements->withTrashed()->count());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\DeleteJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Tests for Group::emailExists()
*/
public function testEmailExists(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$this->assertFalse(Group::emailExists('unknown@domain.tld'));
$this->assertTrue(Group::emailExists($group->email));
$result = Group::emailExists($group->email, true);
$this->assertSame($result->id, $group->id);
$group->delete();
$this->assertTrue(Group::emailExists($group->email));
$result = Group::emailExists($group->email, true);
$this->assertSame($result->id, $group->id);
}
+ /*
+ * Test group restoring
+ */
+ public function testRestore(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $group = $this->getTestGroup('group-test@kolabnow.com');
+ $group->assignToWallet($user->wallets->first());
+
+ $entitlements = \App\Entitlement::where('entitleable_id', $group->id);
+
+ $this->assertSame(1, $entitlements->count());
+
+ $group->delete();
+
+ $this->assertTrue($group->fresh()->trashed());
+ $this->assertSame(0, $entitlements->count());
+ $this->assertSame(1, $entitlements->withTrashed()->count());
+
+ Queue::fake();
+
+ $group->restore();
+ $group->refresh();
+
+ $this->assertFalse($group->trashed());
+ $this->assertFalse($group->isDeleted());
+ $this->assertFalse($group->isSuspended());
+ $this->assertFalse($group->isLdapReady());
+ $this->assertTrue($group->isActive());
+
+ $this->assertSame(1, $entitlements->count());
+ $entitlements->get()->each(function ($ent) {
+ $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
+ });
+
+ Queue::assertPushed(\App\Jobs\Group\CreateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\Group\CreateJob::class,
+ function ($job) use ($group) {
+ $groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
+ $groupId = TestCase::getObjectProperty($job, 'groupId');
+
+ return $groupEmail === $group->email
+ && $groupId === $group->id;
+ }
+ );
+ }
+
/**
* Tests for GroupSettingsTrait functionality and GroupSettingObserver
*/
public function testSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$group = $this->getTestGroup('group-test@kolabnow.com');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Add a setting
$group->setSetting('unknown', 'test');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Add a setting that is synced to LDAP
$group->setSetting('sender_policy', '[]');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
// Note: We test both current group as well as fresh group object
// to make sure cache works as expected
$this->assertSame('test', $group->getSetting('unknown'));
$this->assertSame('[]', $group->fresh()->getSetting('sender_policy'));
Queue::fake();
// Update a setting
$group->setSetting('unknown', 'test1');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Update a setting that is synced to LDAP
$group->setSetting('sender_policy', '["-"]');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$this->assertSame('test1', $group->getSetting('unknown'));
$this->assertSame('["-"]', $group->fresh()->getSetting('sender_policy'));
Queue::fake();
// Delete a setting (null)
$group->setSetting('unknown', null);
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Delete a setting that is synced to LDAP
$group->setSetting('sender_policy', null);
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$this->assertSame(null, $group->getSetting('unknown'));
$this->assertSame(null, $group->fresh()->getSetting('sender_policy'));
}
/**
* Tests for Group::suspend()
*/
public function testSuspend(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->suspend();
$this->assertTrue($group->isSuspended());
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\UpdateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Test updating a group
*/
public function testUpdate(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->status |= Group::STATUS_DELETED;
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\UpdateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Tests for Group::unsuspend()
*/
public function testUnsuspend(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->status = Group::STATUS_SUSPENDED;
$group->unsuspend();
$this->assertFalse($group->isSuspended());
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\UpdateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Feb 2, 12:53 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426836
Default Alt Text
(83 KB)

Event Timeline