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