Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2527508
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
122 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php
index 97d972b3..738b23e9 100644
--- a/src/app/Console/Command.php
+++ b/src/app/Console/Command.php
@@ -1,235 +1,260 @@
<?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;
+
+ /**
+ * Shortcut to creating a progress bar of a particular format with a particular message.
+ *
+ * @param int $count Number of progress steps
+ * @param string $message The description
+ *
+ * @return \Symfony\Component\Console\Helper\ProgressBar
+ */
+ protected function createProgressBar($count, $message = null)
+ {
+ $bar = $this->output->createProgressBar($count);
+ $bar->setFormat(
+ '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% '
+ );
+
+ if ($message) {
+ $bar->setMessage("{$message}...");
+ }
+
+ $bar->start();
+
+ return $bar;
+ }
+
/**
* 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\Resource::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 a resource.
*
* @param string $resource Resource ID or email
* @param bool $withDeleted Include deleted
*
* @return \App\Resource|null
*/
public function getResource($resource, $withDeleted = false)
{
return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted);
}
/**
* 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/Data/Import/IP4NetsCommand.php b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php
index c2e83e6f..ee0935d8 100644
--- a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php
+++ b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php
@@ -1,215 +1,211 @@
<?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';
/**
* 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}"
- );
+ $bar = $this->createProgressBar($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 b71e532b..4daec217 100644
--- a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php
+++ b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php
@@ -1,213 +1,209 @@
<?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.';
/**
* 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}"
- );
+ $bar = $this->createProgressBar($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/Import/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php
new file mode 100644
index 00000000..675564f2
--- /dev/null
+++ b/src/app/Console/Commands/Data/Import/LdifCommand.php
@@ -0,0 +1,1000 @@
+<?php
+
+namespace App\Console\Commands\Data\Import;
+
+use App\Console\Command;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class LdifCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'data:import:ldif {file} {owner} {--force}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Migrate data from an LDIF file';
+
+ /** @var array Aliases email addresses of the owner */
+ protected $aliases = [];
+
+ /** @var array List of imported domains */
+ protected $domains = [];
+
+ /** @var ?string LDAP DN of the account owner */
+ protected $ownerDN;
+
+ /** @var array Packages information */
+ protected $packages = [];
+
+ /** @var ?\App\Wallet A wallet of the account owner */
+ protected $wallet;
+
+ /** @var string Temp table name */
+ protected static $table = 'tmp_ldif_import';
+
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ ini_set("memory_limit", "2048M");
+
+ // (Re-)create temporary table
+ Schema::dropIfExists(self::$table);
+ Schema::create(
+ self::$table,
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->text('dn')->index();
+ $table->string('type')->nullable()->index();
+ $table->text('data')->nullable();
+ $table->text('error')->nullable();
+ $table->text('warning')->nullable();
+ }
+ );
+
+ // Import data from the file to the temp table
+ $this->loadFromFile();
+
+ // Check for errors in the data, print them and abort (if not using --force)
+ if ($this->printErrors()) {
+ return 1;
+ }
+
+ // Prepare packages/skus information
+ $this->preparePackagesAndSkus();
+
+ // Import the account owner first
+ $this->importOwner();
+
+ // Import domains first
+ $this->importDomains();
+
+ // Import other objects
+ $this->importUsers();
+ $this->importSharedFolders();
+ $this->importResources();
+ $this->importGroups();
+
+ // Print warnings collected in the whole process
+ $this->printWarnings();
+
+ // Finally, drop the temp table
+ Schema::dropIfExists(self::$table);
+ }
+
+ /**
+ * Check if a domain exists
+ */
+ protected function domainExists($domain): bool
+ {
+ return in_array($domain, $this->domains);
+ }
+
+ /**
+ * Load data from the LDIF file into the temp table
+ */
+ protected function loadFromFile(): void
+ {
+ $file = $this->argument('file');
+
+ $numLines = \App\Utils::countLines($file);
+
+ $bar = $this->createProgressBar($numLines, "Parsing input file");
+
+ $fh = fopen($file, 'r');
+
+ $inserts = [];
+ $entry = [];
+ $lastAttr = null;
+
+ $insertFunc = function ($limit = 0) use (&$entry, &$inserts) {
+ if (!empty($entry)) {
+ if ($entry = $this->parseLDAPEntry($entry)) {
+ $inserts[] = $entry;
+ }
+ $entry = [];
+ }
+
+ if (count($inserts) > $limit) {
+ DB::table(self::$table)->insert($inserts);
+ $inserts = [];
+ }
+ };
+
+ while (!feof($fh)) {
+ $line = rtrim(fgets($fh));
+
+ $bar->advance();
+
+ if (trim($line) === '' || $line[0] === '#') {
+ continue;
+ }
+
+ if (substr($line, 0, 3) == 'dn:') {
+ $insertFunc(20);
+ $entry['dn'] = strtolower(substr($line, 4));
+ $lastAttr = 'dn';
+ } elseif (substr($line, 0, 1) == ' ') {
+ if (is_array($entry[$lastAttr])) {
+ $elemNum = count($entry[$lastAttr]) - 1;
+ $entry[$lastAttr][$elemNum] .= ltrim($line);
+ } else {
+ $entry[$lastAttr] .= ltrim($line);
+ }
+ } else {
+ list ($attr, $remainder) = explode(':', $line, 2);
+ $attr = strtolower($attr);
+
+ if ($remainder[0] === ':') {
+ $remainder = base64_decode(substr($remainder, 2));
+ } else {
+ $remainder = ltrim($remainder);
+ }
+
+ if (array_key_exists($attr, $entry)) {
+ if (!is_array($entry[$attr])) {
+ $entry[$attr] = [$entry[$attr]];
+ }
+
+ $entry[$attr][] = $remainder;
+ } else {
+ $entry[$attr] = $remainder;
+ }
+
+ $lastAttr = $attr;
+ }
+ }
+
+ $insertFunc();
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Import domains from the temp table
+ */
+ protected function importDomains(): void
+ {
+ $domains = DB::table(self::$table)->where('type', 'domain')->whereNull('error')->get();
+
+ $bar = $this->createProgressBar(count($domains), "Importing domains");
+
+ foreach ($domains as $_domain) {
+ $bar->advance();
+
+ $data = json_decode($_domain->data);
+
+ $domain = \App\Domain::withTrashed()->where('namespace', $data->namespace)->first();
+
+ if ($domain) {
+ $this->setImportWarning($_domain->id, "Domain already exists");
+ continue;
+ }
+
+ $domain = \App\Domain::create([
+ 'namespace' => $data->namespace,
+ 'type' => \App\Domain::TYPE_EXTERNAL,
+ ]);
+
+ // Entitlements
+ $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet);
+
+ $this->domains[] = $domain->namespace;
+
+ if (!empty($data->aliases)) {
+ foreach ($data->aliases as $alias) {
+ $alias = strtolower($alias);
+ $domain = \App\Domain::withTrashed()->where('namespace', $alias)->first();
+
+ if ($domain) {
+ $this->setImportWarning($_domain->id, "Domain already exists");
+ continue;
+ }
+
+ $domain = \App\Domain::create([
+ 'namespace' => $alias,
+ 'type' => \App\Domain::TYPE_EXTERNAL,
+ ]);
+
+ // Entitlements
+ $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet);
+
+ $this->domains[] = $domain->namespace;
+ }
+ }
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Import groups from the temp table
+ */
+ protected function importGroups(): void
+ {
+ $groups = DB::table(self::$table)->where('type', 'group')->whereNull('error')->get();
+
+ $bar = $this->createProgressBar(count($groups), "Importing groups");
+
+ foreach ($groups as $_group) {
+ $bar->advance();
+
+ $data = json_decode($_group->data);
+
+ // Collect group member email addresses
+ $members = $this->resolveUserDNs($data->members);
+
+ if (empty($members)) {
+ $this->setImportWarning($_group->id, "Members resolve to an empty array");
+ continue;
+ }
+
+ $group = \App\Group::withTrashed()->where('email', $data->email)->first();
+
+ if ($group) {
+ $this->setImportWarning($_group->id, "Group already exists");
+ continue;
+ }
+
+ // Make sure the domain exists
+ if (!$this->domainExists($data->domain)) {
+ $this->setImportWarning($_group->id, "Domain not found");
+ continue;
+ }
+
+ $group = \App\Group::create([
+ 'name' => $data->name,
+ 'email' => $data->email,
+ 'members' => $members,
+ ]);
+
+ $group->assignToWallet($this->wallet);
+
+ // Sender policy
+ if (!empty($data->sender_policy)) {
+ $group->setSetting('sender_policy', json_encode($data->sender_policy));
+ }
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Import resources from the temp table
+ */
+ protected function importResources(): void
+ {
+ $resources = DB::table(self::$table)->where('type', 'resource')->whereNull('error')->get();
+
+ $bar = $this->createProgressBar(count($resources), "Importing resources");
+
+ foreach ($resources as $_resource) {
+ $bar->advance();
+
+ $data = json_decode($_resource->data);
+
+ $resource = \App\Resource::withTrashed()
+ ->where('name', $data->name)
+ ->where('email', 'like', '%@' . $data->domain)
+ ->first();
+
+ if ($resource) {
+ $this->setImportWarning($_resource->id, "Resource already exists");
+ continue;
+ }
+
+ // Resource invitation policy
+ if (!empty($data->invitation_policy) && $data->invitation_policy == 'manual') {
+ $members = empty($data->owner) ? [] : $this->resolveUserDNs([$data->owner]);
+
+ if (empty($members)) {
+ $this->setImportWarning($_resource->id, "Failed to resolve the resource owner");
+ $data->invitation_policy = null;
+ } else {
+ $data->invitation_policy = 'manual:' . $members[0];
+ }
+ }
+
+ // Make sure the domain exists
+ if (!$this->domainExists($data->domain)) {
+ $this->setImportWarning($_resource->id, "Domain not found");
+ continue;
+ }
+
+ $resource = new \App\Resource();
+ $resource->name = $data->name;
+ $resource->domain = $data->domain;
+ $resource->save();
+
+ $resource->assignToWallet($this->wallet);
+
+ // Invitation policy
+ if (!empty($data->invitation_policy)) {
+ $resource->setSetting('invitation_policy', $data->invitation_policy);
+ }
+
+ // Target folder
+ if (!empty($data->folder)) {
+ $resource->setSetting('folder', $data->folder);
+ }
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Import shared folders from the temp table
+ */
+ protected function importSharedFolders(): void
+ {
+ $folders = DB::table(self::$table)->where('type', 'sharedFolder')->whereNull('error')->get();
+
+ $bar = $this->createProgressBar(count($folders), "Importing shared folders");
+
+ foreach ($folders as $_folder) {
+ $bar->advance();
+
+ $data = json_decode($_folder->data);
+
+ $folder = \App\SharedFolder::withTrashed()
+ ->where('name', $data->name)
+ ->where('email', 'like', '%@' . $data->domain)
+ ->first();
+
+ if ($folder) {
+ $this->setImportWarning($_folder->id, "Folder already exists");
+ continue;
+ }
+
+ // Make sure the domain exists
+ if (!$this->domainExists($data->domain)) {
+ $this->setImportWarning($_folder->id, "Domain not found");
+ continue;
+ }
+
+ $folder = new \App\SharedFolder();
+ $folder->name = $data->name;
+ $folder->type = $data->type ?? 'mail';
+ $folder->domain = $data->domain;
+ $folder->save();
+
+ $folder->assignToWallet($this->wallet);
+
+ // Invitation policy
+ if (!empty($data->acl)) {
+ $folder->setSetting('acl', json_encode($data->acl));
+ }
+
+ // Target folder
+ if (!empty($data->folder)) {
+ $folder->setSetting('folder', $data->folder);
+ }
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Import users from the temp table
+ */
+ protected function importUsers(): void
+ {
+ $users = DB::table(self::$table)->where('type', 'user')->whereNull('error');
+
+ // Skip the (already imported) account owner
+ if ($this->ownerDN) {
+ $users->whereNotIn('dn', [$this->ownerDN]);
+ }
+
+ // Import aliases of the owner, we got from importOwner() call
+ if (!empty($this->aliases) && $this->wallet) {
+ $this->setUserAliases($this->wallet->owner, $this->aliases);
+ }
+
+ $bar = $this->createProgressBar($users->count(), "Importing users");
+
+ foreach ($users->cursor() as $_user) {
+ $bar->advance();
+
+ $this->importSingleUser($_user);
+ }
+
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ /**
+ * Import the account owner (or find it among the existing accounts)
+ */
+ protected function importOwner(): void
+ {
+ // The owner email not found in the import data, try existing users
+ $user = $this->getUser($this->argument('owner'));
+
+ if (!$user && $this->ownerDN) {
+ // The owner email found in the import data
+ $bar = $this->createProgressBar(1, "Importing account owner");
+
+ $user = DB::table(self::$table)->where('dn', $this->ownerDN)->first();
+ $user = $this->importSingleUser($user);
+
+ // TODO: We should probably make sure the user's domain is to be imported too
+ // and/or create it automatically.
+
+ $bar->advance();
+ $bar->finish();
+
+ $this->info("DONE");
+ }
+
+ if (!$user) {
+ $this->error("Unable to find the specified account owner");
+ exit(1);
+ }
+
+ $this->wallet = $user->wallets->first();
+ }
+
+ /**
+ * A helper that imports a single user record
+ */
+ protected function importSingleUser($ldap_user)
+ {
+ $data = json_decode($ldap_user->data);
+
+ $user = \App\User::withTrashed()->where('email', $data->email)->first();
+
+ if ($user) {
+ $this->setImportWarning($ldap_user->id, "User already exists");
+ return;
+ }
+
+ // Make sure the domain exists
+ if ($this->wallet && !$this->domainExists($data->domain)) {
+ $this->setImportWarning($ldap_user->id, "Domain not found");
+ return;
+ }
+
+ $user = \App\User::create(['email' => $data->email]);
+
+ // Entitlements
+ $user->assignPackageAndWallet($this->packages['user'], $this->wallet ?: $user->wallets()->first());
+
+ if (!empty($data->quota)) {
+ $quota = ceil($data->quota / 1024 / 1024) - $this->packages['quota'];
+ if ($quota > 0) {
+ $user->assignSku($this->packages['storage'], $quota);
+ }
+ }
+
+ // User settings
+ if (!empty($data->settings)) {
+ $settings = [];
+ foreach ($data->settings as $key => $value) {
+ $settings[] = [
+ 'user_id' => $user->id,
+ 'key' => $key,
+ 'value' => $value,
+ ];
+ }
+
+ DB::table('user_settings')->insert($settings);
+ }
+
+ // Update password
+ if ($data->password != $user->password_ldap) {
+ \App\User::where('id', $user->id)->update(['password_ldap' => $data->password]);
+ }
+
+ // Import aliases
+ if (!empty($data->aliases)) {
+ if (!$this->wallet) {
+ // This is the account owner creation, at this point we likely do not have
+ // domain records yet, save the aliases to be inserted later (in importUsers())
+ $this->aliases = $data->aliases;
+ } else {
+ $this->setUserAliases($user, $data->aliases);
+ }
+ }
+
+ return $user;
+ }
+
+ /**
+ * Convert LDAP entry into an object supported by the migration tool
+ *
+ * @param array $entry LDAP entry attributes
+ *
+ * @return array Record data for inserting to the temp table
+ */
+ protected function parseLDAPEntry(array $entry): array
+ {
+ $type = null;
+ $data = null;
+ $error = null;
+
+ $ouTypeMap = [
+ 'Shared Folders' => 'sharedfolder',
+ 'Resources' => 'resource',
+ 'Groups' => 'group',
+ 'People' => 'user',
+ 'Domains' => 'domain',
+ ];
+
+ foreach ($ouTypeMap as $ou => $_type) {
+ if (stripos($entry['dn'], ",ou={$ou}")) {
+ $type = $_type;
+ break;
+ }
+ }
+
+ if (!$type) {
+ $error = "Unknown record type";
+ }
+
+ if (empty($error)) {
+ $method = 'parseLDAP' . ucfirst($type);
+ list($data, $error) = $this->{$method}($entry);
+
+ if (empty($data['domain']) && !empty($data['email'])) {
+ $data['domain'] = explode('@', $data['email'])[1];
+ }
+ }
+
+ return [
+ 'dn' => $entry['dn'],
+ 'type' => $type,
+ 'data' => json_encode($data),
+ 'error' => $error,
+ ];
+ }
+
+ /**
+ * Convert LDAP domain data into Kolab4 "format"
+ */
+ protected function parseLDAPDomain($entry)
+ {
+ $error = null;
+ $result = [];
+
+ if (empty($entry['associateddomain'])) {
+ $error = "Missing 'associatedDomain' attribute";
+ } elseif (!empty($entry['inetdomainstatus']) && $entry['inetdomainstatus'] == 'deleted') {
+ $error = "Domain deleted";
+ } else {
+ $result['namespace'] = strtolower($this->attrStringValue($entry, 'associateddomain'));
+
+ if (is_array($entry['associateddomain']) && count($entry['associateddomain']) > 1) {
+ $result['aliases'] = array_slice($entry['associateddomain'], 1);
+ }
+
+ // TODO: inetdomainstatus = suspended ???
+ }
+
+ return [$result, $error];
+ }
+
+ /**
+ * Convert LDAP group data into Kolab4 "format"
+ */
+ protected function parseLDAPGroup($entry)
+ {
+ $error = null;
+ $result = [];
+
+ if (empty($entry['cn'])) {
+ $error = "Missing 'cn' attribute";
+ } elseif (empty($entry['mail'])) {
+ $error = "Missing 'mail' attribute";
+ } elseif (empty($entry['uniquemember'])) {
+ $error = "Missing 'uniqueMember' attribute";
+ } else {
+ $result['name'] = $this->attrStringValue($entry, 'cn');
+ $result['email'] = strtolower($this->attrStringValue($entry, 'mail'));
+ $result['members'] = $this->attrArrayValue($entry, 'uniquemember');
+
+ if (!empty($entry['kolaballowsmtpsender'])) {
+ $policy = $this->attrArrayValue($entry, 'kolaballowsmtpsender');
+ $result['sender_policy'] = $this->parseSenderPolicy($policy);
+ }
+ }
+
+ return [$result, $error];
+ }
+
+ /**
+ * Convert LDAP resource data into Kolab4 "format"
+ */
+ protected function parseLDAPResource($entry)
+ {
+ $error = null;
+ $result = [];
+
+ if (empty($entry['cn'])) {
+ $error = "Missing 'cn' attribute";
+ } elseif (empty($entry['mail'])) {
+ $error = "Missing 'mail' attribute";
+ } else {
+ $result['name'] = $this->attrStringValue($entry, 'cn');
+ $result['email'] = strtolower($this->attrStringValue($entry, 'mail'));
+
+ if (!empty($entry['kolabtargetfolder'])) {
+ $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder');
+ }
+
+ if (!empty($entry['owner'])) {
+ $result['owner'] = $this->attrStringValue($entry, 'owner');
+ }
+
+ if (!empty($entry['kolabinvitationpolicy'])) {
+ $policy = $this->attrArrayValue($entry, 'kolabinvitationpolicy');
+ $result['invitation_policy'] = $this->parseInvitationPolicy($policy);
+ }
+ }
+
+ return [$result, $error];
+ }
+
+ /**
+ * Convert LDAP shared folder data into Kolab4 "format"
+ */
+ protected function parseLDAPSharedFolder($entry)
+ {
+ $error = null;
+ $result = [];
+
+ if (empty($entry['cn'])) {
+ $error = "Missing 'cn' attribute";
+ } elseif (empty($entry['mail'])) {
+ $error = "Missing 'mail' attribute";
+ } else {
+ $result['name'] = $this->attrStringValue($entry, 'cn');
+ $result['email'] = strtolower($this->attrStringValue($entry, 'mail'));
+
+ if (!empty($entry['kolabfoldertype'])) {
+ $result['type'] = $this->attrStringValue($entry, 'kolabfoldertype');
+ }
+
+ if (!empty($entry['kolabtargetfolder'])) {
+ $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder');
+ }
+
+ if (!empty($entry['acl'])) {
+ $result['acl'] = $this->parseACL($this->attrArrayValue($entry, 'acl'));
+ }
+ }
+
+ return [$result, $error];
+ }
+
+ /**
+ * Convert LDAP user data into Kolab4 "format"
+ */
+ protected function parseLDAPUser($entry)
+ {
+ $error = null;
+ $result = [];
+
+ $settingAttrs = [
+ 'givenname' => 'first_name',
+ 'sn' => 'last_name',
+ 'telephonenumber' => 'phone',
+ 'mailalternateaddress' => 'external_email',
+ 'mobile' => 'phone',
+ 'o' => 'organization',
+ // 'address' => 'billing_address'
+ ];
+
+ if (empty($entry['mail'])) {
+ $error = "Missing 'mail' attribute";
+ } else {
+ $result['email'] = strtolower($this->attrStringValue($entry, 'mail'));
+ $result['settings'] = [];
+ $result['aliases'] = [];
+
+ foreach ($settingAttrs as $attr => $setting) {
+ if (!empty($entry[$attr])) {
+ $result['settings'][$setting] = $this->attrStringValue($entry, $attr);
+ }
+ }
+
+ if (!empty($entry['alias'])) {
+ $result['aliases'] = $this->attrArrayValue($entry, 'alias');
+ }
+
+ if (!empty($entry['userpassword'])) {
+ $result['password'] = $this->attrStringValue($entry, 'userpassword');
+ }
+
+ if (!empty($entry['mailquota'])) {
+ $result['quota'] = $this->attrStringValue($entry, 'mailquota');
+ }
+
+ if ($result['email'] == $this->argument('owner')) {
+ $this->ownerDN = $entry['dn'];
+ }
+ }
+
+ return [$result, $error];
+ }
+
+ /**
+ * Print import errors
+ */
+ protected function printErrors(): bool
+ {
+ if ($this->option('force')) {
+ return false;
+ }
+
+ $errors = DB::table(self::$table)->whereNotNull('error')->orderBy('id')
+ ->get()
+ ->map(function ($record) {
+ $this->error("ERROR {$record->dn}: {$record->error}");
+ return $record->id;
+ })
+ ->all();
+
+ return !empty($errors);
+ }
+
+ /**
+ * Print import warnings (for records that do not have an error specified)
+ */
+ protected function printWarnings(): void
+ {
+ DB::table(self::$table)->whereNotNull('warning')->whereNull('error')->orderBy('id')
+ ->each(function ($record) {
+ $this->warn("WARNING {$record->dn}: {$record->warning}");
+ return $record->id;
+ });
+ }
+
+ /**
+ * Convert ldap attribute value to an array
+ */
+ protected static function attrArrayValue($entry, $attribute)
+ {
+ return is_array($entry[$attribute]) ? $entry[$attribute] : [$entry[$attribute]];
+ }
+
+ /**
+ * Convert ldap attribute to a string
+ */
+ protected static function attrStringValue($entry, $attribute)
+ {
+ return is_array($entry[$attribute]) ? $entry[$attribute][0] : $entry[$attribute];
+ }
+
+ /**
+ * Resolve a list of user DNs into email addresses. Makes sure
+ * the returned addresses exist in Kolab4 database.
+ */
+ protected function resolveUserDNs($user_dns): array
+ {
+ // Get email addresses from the import data
+ $users = DB::table(self::$table)->whereIn('dn', $user_dns)
+ ->where('type', 'user')
+ ->whereNull('error')
+ ->get()
+ ->map(function ($user) {
+ $mdata = json_decode($user->data);
+ return $mdata->email;
+ })
+ // Make sure to skip these with unknown domains
+ ->filter(function ($email) {
+ return $this->domainExists(explode('@', $email)[1]);
+ })
+ ->all();
+
+ // Get email addresses for existing Kolab4 users
+ if (!empty($users)) {
+ $users = \App\User::whereIn('email', $users)->get()->pluck('email')->all();
+ }
+
+ return $users;
+ }
+
+ /**
+ * Validate/convert acl to Kolab4 format
+ */
+ protected static function parseACL(array $acl): array
+ {
+ $map = [
+ 'lrswipkxtecdn' => 'full',
+ 'lrs' => 'read-only',
+ 'read' => 'read-only',
+ 'lrswitedn' => 'read-write',
+ ];
+
+ $supportedRights = ['full', 'read-only', 'read-write'];
+
+ foreach ($acl as $idx => $entry) {
+ $parts = explode(',', $entry);
+ $entry = null;
+
+ if (count($parts) == 2) {
+ $label = trim($parts[0]);
+ $rights = trim($parts[1]);
+ $rights = $map[$rights] ?? $rights;
+
+ if (in_array($rights, $supportedRights) && ($label === 'anyone' || strpos($label, '@'))) {
+ $entry = "{$label}, {$rights}";
+ }
+
+ // TODO: Throw an error or log a warning on unsupported acl entry?
+ }
+
+ $acl[$idx] = $entry;
+ }
+
+ return array_values(array_filter($acl));
+ }
+
+ /**
+ * Validate/convert invitation policy to Kolab4 format
+ */
+ protected static function parseInvitationPolicy(array $policies): ?string
+ {
+ foreach ($policies as $policy) {
+ if ($policy == 'ACT_MANUAL') {
+ // 'owner' attribute handling in another place
+ return 'manual';
+ }
+
+ if ($policy == 'ACT_ACCEPT_AND_NOTIFY') {
+ break; // use the default 'accept' (null) policy
+ }
+
+ if ($policy == 'ACT_REJECT') {
+ return 'reject';
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Validate/convert sender policy to Kolab4 format
+ */
+ protected static function parseSenderPolicy(array $rules): array
+ {
+ foreach ($rules as $idx => $rule) {
+ $entry = trim($rule);
+ $rule = null;
+
+ // 'deny' rules aren't supported
+ if (isset($entry[0]) && $entry[0] !== '-') {
+ $rule = $entry;
+ }
+
+ $rules[$idx] = $rule;
+ }
+
+ $rules = array_values(array_filter($rules));
+
+ if (!empty($rules) && $rules[count($rules) - 1] != '-') {
+ $rules[] = '-';
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Get/prepare packages/skus information
+ */
+ protected function preparePackagesAndSkus(): void
+ {
+ // Find the tenant
+ if (empty($this->ownerDN)) {
+ if ($user = $this->getUser($this->argument('owner'))) {
+ $tenant_id = $user->tenant_id;
+ }
+ }
+
+ // TODO: Tenant id could be a command option
+
+ if (empty($tenant_id)) {
+ $tenant_id = \config('app.tenant_id');
+ }
+
+ // TODO: We should probably make package titles configurable with command options
+
+ $this->packages = [
+ 'user' => \App\Package::where('title', 'kolab')->where('tenant_id', $tenant_id)->first(),
+ 'domain' => \App\Package::where('title', 'domain-hosting')->where('tenant_id', $tenant_id)->first(),
+ ];
+
+ // Count storage skus
+ $sku = $this->packages['user']->skus()->where('title', 'storage')->first();
+
+ $this->packages['quota'] = $sku ? $sku->pivot->qty : 0;
+ $this->packages['storage'] = \App\Sku::where('title', 'storage')->where('tenant_id', $tenant_id)->first();
+ }
+
+ /**
+ * Set aliases for the user
+ */
+ protected function setUserAliases(\App\User $user, array $aliases = [])
+ {
+ if (!empty($aliases)) {
+ // Some users might have alias entry with their main address, remove it
+ $aliases = array_map('strtolower', $aliases);
+ $aliases = array_diff(array_unique($aliases), [$user->email]);
+
+ // Remove aliases for domains that do not exist
+ if (!empty($aliases)) {
+ $aliases = array_filter(
+ $aliases,
+ function ($alias) {
+ return $this->domainExists(explode('@', $alias)[1]);
+ }
+ );
+ }
+
+ if (!empty($aliases)) {
+ $user->setAliases($aliases);
+ }
+ }
+ }
+
+ /**
+ * Set error message for specified import data record
+ */
+ protected static function setImportError($id, $error): void
+ {
+ DB::table(self::$table)->where('id', $id)->update(['error' => $error]);
+ }
+
+ /**
+ * Set warning message for specified import data record
+ */
+ protected static function setImportWarning($id, $warning): void
+ {
+ DB::table(self::$table)->where('id', $id)->update(['warning' => $warning]);
+ }
+}
diff --git a/src/app/Console/Commands/MigratePrices.php b/src/app/Console/Commands/MigratePrices.php
index 46905afa..8e517968 100644
--- a/src/app/Console/Commands/MigratePrices.php
+++ b/src/app/Console/Commands/MigratePrices.php
@@ -1,156 +1,156 @@
<?php
namespace App\Console\Commands;
-use Illuminate\Console\Command;
+use App\Console\Command;
class MigratePrices extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'migrate:prices';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Apply a new price list';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->updateSKUs();
$this->updateEntitlements();
}
private function updateSKUs()
{
- $bar = \App\Utils::createProgressBar($this->output, 8, "Updating SKUs");
+ $bar = $this->createProgressBar(8, "Updating SKUs");
// 1. Set the list price for the SKU 'mailbox' to 500.
$bar->advance();
$mailbox_sku = \App\Sku::where('title', 'mailbox')->first();
$mailbox_sku->cost = 500;
$mailbox_sku->save();
// 2. Set the list price for the SKU 'groupware' to 490.
$bar->advance();
$groupware_sku = \App\Sku::where('title', 'groupware')->first();
$groupware_sku->cost = 490;
$groupware_sku->save();
// 3. Set the list price for the SKU 'activesync' to 0.
$bar->advance();
$activesync_sku = \App\Sku::where('title', 'activesync')->first();
$activesync_sku->cost = 0;
$activesync_sku->save();
// 4. Set the units free for the SKU 'storage' to 5.
$bar->advance();
$storage_sku = \App\Sku::where('title', 'storage')->first();
$storage_sku->units_free = 5;
$storage_sku->save();
// 5. Set the number of units for storage to 5 for the 'lite' and 'kolab' packages.
$bar->advance();
$kolab_package = \App\Package::where('title', 'kolab')->first();
$kolab_package->skus()->updateExistingPivot($storage_sku, ['qty' => 5], false);
$lite_package = \App\Package::where('title', 'lite')->first();
$lite_package->skus()->updateExistingPivot($storage_sku, ['qty' => 5], false);
// 6. Set the cost for the 'mailbox' unit for the 'lite' and 'kolab' packages to 500.
$bar->advance();
$kolab_package->skus()->updateExistingPivot($mailbox_sku, ['cost' => 500], false);
$lite_package->skus()->updateExistingPivot($mailbox_sku, ['cost' => 500], false);
// 7. Set the cost for the 'groupware' unit for the 'kolab' package to 490.
$bar->advance();
$kolab_package->skus()->updateExistingPivot($groupware_sku, ['cost' => 490], false);
// 8. Set the cost for the 'activesync' unit for the 'kolab' package to 0.
$bar->advance();
$kolab_package->skus()->updateExistingPivot($activesync_sku, ['cost' => 0], false);
$bar->finish();
$this->info("DONE");
}
private function updateEntitlements()
{
$users = \App\User::all();
- $bar = \App\Utils::createProgressBar($this->output, count($users), "Updating entitlements");
+ $bar = $this->createProgressBar(count($users), "Updating entitlements");
$groupware_sku = \App\Sku::where('title', 'groupware')->first();
$activesync_sku = \App\Sku::where('title', 'activesync')->first();
$storage_sku = \App\Sku::where('title', 'storage')->first();
$mailbox_sku = \App\Sku::where('title', 'mailbox')->first();
foreach ($users as $user) {
$bar->advance();
// 1. For every user with a mailbox, ensure that there's a minimum of 5 storage entitlements
// that are free of charge.
// A. For existing storage entitlements reduce the price to 0 until there's 5 of those.
// B. Do not touch the entitlement's updated_at column.
$mailbox = $user->entitlements()->where('sku_id', $mailbox_sku->id)->first();
if ($mailbox) {
$storage = $user->entitlements()->where('sku_id', $storage_sku->id)
->orderBy('cost')->orderBy('updated_at')->get();
$num = 0;
foreach ($storage as $entitlement) {
$num++;
if ($num <= 5 && $entitlement->cost) {
$entitlement->timestamps = false;
$entitlement->cost = 0;
$entitlement->save();
}
}
if ($num < 5) {
$user->assignSku($storage_sku, 5 - $num);
}
}
// 2. For every user with a 'groupware' entitlement, set the price of that entitlement to 490
// -- without touching updated_at.
$entitlement = $user->entitlements()->where('sku_id', $groupware_sku->id)->first();
if ($entitlement) {
$entitlement->timestamps = false;
$entitlement->cost = 490;
$entitlement->save();
$entitlement = $user->entitlements()->where('sku_id', $mailbox_sku->id)->first();
if ($entitlement) {
$entitlement->timestamps = false;
$entitlement->cost = 500;
$entitlement->save();
}
}
// 3. For every user with an 'activesync' entitlement, set the price for that entitlement to 0
// -- without touching updated_at.
$entitlement = $user->entitlements()->where('sku_id', $activesync_sku->id)->first();
if ($entitlement) {
$entitlement->timestamps = false;
$entitlement->cost = 0;
$entitlement->save();
}
}
$bar->finish();
$this->info("DONE");
}
}
diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php
index 0b5a5c15..83cdfece 100644
--- a/src/app/Observers/ResourceObserver.php
+++ b/src/app/Observers/ResourceObserver.php
@@ -1,104 +1,104 @@
<?php
namespace App\Observers;
use App\Resource;
class ResourceObserver
{
/**
* Handle the resource "creating" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function creating(Resource $resource): void
{
if (empty($resource->email)) {
- if (!isset($resource->name)) {
+ if (!isset($resource->domain)) {
throw new \Exception("Missing 'domain' property for a new resource");
}
$domainName = \strtolower($resource->domain);
$resource->email = "resource-{$resource->id}@{$domainName}";
} else {
$resource->email = \strtolower($resource->email);
}
$resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
}
/**
* Handle the resource "created" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function created(Resource $resource)
{
$domainName = explode('@', $resource->email, 2)[1];
$settings = [
'folder' => "shared/Resources/{$resource->name}@{$domainName}",
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'resource_id' => $resource->id,
];
}
// Note: Don't use setSettings() here to bypass ResourceSetting observers
// Note: This is a single multi-insert query
$resource->settings()->insert(array_values($settings));
// Create resource record in LDAP, then check if it is created in IMAP
$chain = [
new \App\Jobs\Resource\VerifyJob($resource->id),
];
\App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id);
}
/**
* Handle the resource "deleted" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function deleted(Resource $resource)
{
if ($resource->isForceDeleting()) {
return;
}
\App\Jobs\Resource\DeleteJob::dispatch($resource->id);
}
/**
* Handle the resource "updated" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function updated(Resource $resource)
{
\App\Jobs\Resource\UpdateJob::dispatch($resource->id);
// Update the folder property if name changed
if ($resource->name != $resource->getOriginal('name')) {
$domainName = explode('@', $resource->email, 2)[1];
$folder = "shared/Resources/{$resource->name}@{$domainName}";
// Note: This does not invoke ResourceSetting observer events, good.
$resource->settings()->where('key', 'folder')->update(['value' => $folder]);
}
}
}
diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php
index a7330a6e..d8efa588 100644
--- a/src/app/Observers/SharedFolderObserver.php
+++ b/src/app/Observers/SharedFolderObserver.php
@@ -1,108 +1,108 @@
<?php
namespace App\Observers;
use App\SharedFolder;
class SharedFolderObserver
{
/**
* Handle the shared folder "creating" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function creating(SharedFolder $folder): void
{
if (empty($folder->type)) {
$folder->type = 'mail';
}
if (empty($folder->email)) {
- if (!isset($folder->name)) {
+ if (!isset($folder->domain)) {
throw new \Exception("Missing 'domain' property for a new shared folder");
}
$domainName = \strtolower($folder->domain);
$folder->email = "{$folder->type}-{$folder->id}@{$domainName}";
} else {
$folder->email = \strtolower($folder->email);
}
$folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
}
/**
* Handle the shared folder "created" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function created(SharedFolder $folder)
{
$domainName = explode('@', $folder->email, 2)[1];
$settings = [
'folder' => "shared/{$folder->name}@{$domainName}",
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'shared_folder_id' => $folder->id,
];
}
// Note: Don't use setSettings() here to bypass SharedFolderSetting observers
// Note: This is a single multi-insert query
$folder->settings()->insert(array_values($settings));
// Create folder record in LDAP, then check if it is created in IMAP
$chain = [
new \App\Jobs\SharedFolder\VerifyJob($folder->id),
];
\App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id);
}
/**
* Handle the shared folder "deleted" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function deleted(SharedFolder $folder)
{
if ($folder->isForceDeleting()) {
return;
}
\App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id);
}
/**
* Handle the shared folder "updated" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function updated(SharedFolder $folder)
{
\App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id);
// Update the folder property if name changed
if ($folder->name != $folder->getOriginal('name')) {
$domainName = explode('@', $folder->email, 2)[1];
$folderName = "shared/{$folder->name}@{$domainName}";
// Note: This does not invoke SharedFolderSetting observer events, good.
$folder->settings()->where('key', 'folder')->update(['value' => $folderName]);
}
}
}
diff --git a/src/app/User.php b/src/app/User.php
index 56a6b9e0..5d934d40 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,834 +1,835 @@
<?php
namespace App;
use App\UserAlias;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\UserConfigTrait;
use App\Traits\UuidIntKeyTrait;
use App\Traits\SettingsTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Iatstuti\Database\Support\NullableFields;
use Laravel\Passport\HasApiTokens;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
+ * @property string $password_ldap
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable
{
use BelongsToTenantTrait;
use EntitleableTrait;
use HasApiTokens;
use NullableFields;
use UserConfigTrait;
use UserAliasesTrait;
use UuidIntKeyTrait;
use SettingsTrait;
use SoftDeletes;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// user in "limited feature-set" state
public const STATUS_DEGRADED = 1 << 6;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'password_ldap',
'role'
];
protected $nullable = [
'password',
'password_ldap'
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
'App\Wallet', // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Email aliases of this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function aliases()
{
return $this->hasMany('App\UserAlias', 'user_id');
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
return $user->assignPackageAndWallet($package, $this->wallets()->first());
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Degrade the user
*
* @return void
*/
public function degrade(): void
{
if ($this->isDegraded()) {
return;
}
$this->status |= User::STATUS_DEGRADED;
$this->save();
}
/**
* Return the \App\Domain for this user.
*
* @return \App\Domain|null
*/
public function domain()
{
list($local, $domainName) = explode('@', $this->email);
$domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first();
return $domain;
}
/**
* List the domains to which this user is entitled.
*
* @param bool $with_accounts Include domains assigned to wallets
* the current user controls but not owns.
* @param bool $with_public Include active public domains (for the user tenant).
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function domains($with_accounts = true, $with_public = true)
{
$domains = $this->entitleables(Domain::class, $with_accounts);
if ($with_public) {
$domains->orWhere(function ($query) {
if (!$this->tenant_id) {
$query->where('tenant_id', $this->tenant_id);
} else {
$query->withEnvTenantContext();
}
$query->whereRaw(sprintf('(domains.type & %s)', Domain::TYPE_PUBLIC))
->whereRaw(sprintf('(domains.status & %s)', Domain::STATUS_ACTIVE));
});
}
return $domains;
}
/**
* Find whether an email address exists as a user (including deleted users).
*
* @param string $email Email address
* @param bool $return_user Return User instance instead of boolean
*
* @return \App\User|bool True or User model object if found, False otherwise
*/
public static function emailExists(string $email, bool $return_user = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
$user = self::withTrashed()->where('email', $email)->first();
if ($user) {
return $return_user ? $user : true;
}
return false;
}
/**
* Return entitleable objects of a specified type controlled by the current user.
*
* @param string $class Object class
* @param bool $with_accounts Include objects assigned to wallets
* the current user controls, but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
private function entitleables(string $class, bool $with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
$object = new $class();
$table = $object->getTable();
return $object->select("{$table}.*")
->whereExists(function ($query) use ($table, $wallets, $class) {
$query->select(DB::raw(1))
->from('entitlements')
->whereColumn('entitleable_id', "{$table}.id")
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', $class);
});
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User|null User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
return $this->entitleables(Group::class, $with_accounts);
}
/**
* Returns whether this user is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
* Returns whether this user (or its wallet owner) is degraded.
*
* @param bool $owner Check also the wallet owner instead just the user himself
*
* @return bool
*/
public function isDegraded(bool $owner = false): bool
{
if ($this->status & self::STATUS_DEGRADED) {
return true;
}
if ($owner && ($wallet = $this->wallet())) {
return $wallet->owner && $wallet->owner->isDegraded();
}
return false;
}
/**
* Returns whether this user is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return ($this->status & self::STATUS_DELETED) > 0;
}
/**
* Returns whether this user is registered in IMAP.
*
* @return bool
*/
public function isImapReady(): bool
{
return ($this->status & self::STATUS_IMAP_READY) > 0;
}
/**
* Returns whether this user is registered in LDAP.
*
* @return bool
*/
public function isLdapReady(): bool
{
return ($this->status & self::STATUS_LDAP_READY) > 0;
}
/**
* Returns whether this user is new.
*
* @return bool
*/
public function isNew(): bool
{
return ($this->status & self::STATUS_NEW) > 0;
}
/**
* Returns whether this user is suspended.
*
* @return bool
*/
public function isSuspended(): bool
{
return ($this->status & self::STATUS_SUSPENDED) > 0;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$settings = $this->getSettings(['first_name', 'last_name']);
$name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Return resources controlled by the current user.
*
* @param bool $with_accounts Include resources assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function resources($with_accounts = true)
{
return $this->entitleables(\App\Resource::class, $with_accounts);
}
/**
* Return shared folders controlled by the current user.
*
* @param bool $with_accounts Include folders assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function sharedFolders($with_accounts = true)
{
return $this->entitleables(\App\SharedFolder::class, $with_accounts);
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
/**
* Suspend this user.
*
* @return void
*/
public function suspend(): void
{
if ($this->isSuspended()) {
return;
}
$this->status |= User::STATUS_SUSPENDED;
$this->save();
}
/**
* Un-degrade this user.
*
* @return void
*/
public function undegrade(): void
{
if (!$this->isDegraded()) {
return;
}
$this->status ^= User::STATUS_DEGRADED;
$this->save();
}
/**
* Unsuspend this user.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= User::STATUS_SUSPENDED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
return $this->entitleables(User::class, $with_accounts);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany('App\VerificationCode', 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany('App\Wallet');
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
self::STATUS_DEGRADED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
/**
* Validate the user credentials
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param bool $updatePassword Store the password if currently empty
*
* @return bool true on success
*/
public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool
{
$authenticated = false;
if ($this->email === \strtolower($username)) {
if (!empty($this->password)) {
if (Hash::check($password, $this->password)) {
$authenticated = true;
}
} elseif (!empty($this->password_ldap)) {
if (substr($this->password_ldap, 0, 6) == "{SSHA}") {
$salt = substr(base64_decode(substr($this->password_ldap, 6)), 20);
$hash = '{SSHA}' . base64_encode(
sha1($password . $salt, true) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
} elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") {
$salt = substr(base64_decode(substr($this->password_ldap, 9)), 64);
$hash = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password . $salt)) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
}
} else {
\Log::error("Incomplete credentials for {$this->email}");
}
}
if ($authenticated) {
\Log::info("Successful authentication for {$this->email}");
// TODO: update last login time
if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) {
$this->password = $password;
$this->save();
}
} else {
// TODO: Try actual LDAP?
\Log::info("Authentication failed for {$this->email}");
}
return $authenticated;
}
/**
* Retrieve and authenticate a user
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param string $secondFactor The second factor (secondfactor from current request is used as fallback).
*
* @return array ['user', 'reason', 'errorMessage']
*/
public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array
{
$user = User::where('email', $username)->first();
if (!$user) {
return ['reason' => 'notfound', 'errorMessage' => "User not found."];
}
if (!$user->validateCredentials($username, $password)) {
return ['reason' => 'credentials', 'errorMessage' => "Invalid password."];
}
if (!$secondFactor) {
// Check the request if there is a second factor provided
// as fallback.
$secondFactor = request()->secondfactor;
}
try {
(new \App\Auth\SecondFactor($user))->validate($secondFactor);
} catch (\Exception $e) {
return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()];
}
return ['user' => $user];
}
/**
* Hook for passport
*
* @throws \Throwable
*
* @return \App\User User model object if found
*/
public function findAndValidateForPassport($username, $password): User
{
$result = self::findAndAuthenticate($username, $password);
if (isset($result['reason'])) {
if ($result['reason'] == 'secondfactor') {
// This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
}
throw OAuthServerException::invalidCredentials();
}
return $result['user'];
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index 5f852fb2..bd981a2a 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,557 +1,531 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Facades\Cache;
/**
* Small utility functions for App.
*/
class Utils
{
// Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/**
* Count the number of lines in a file.
*
* Useful for progress bars.
*
* @param string $file The filepath to count the lines of.
*
* @return int
*/
public static function countLines($file)
{
$fh = fopen($file, 'rb');
$numLines = 0;
while (!feof($fh)) {
$numLines += substr_count(fread($fh, 8192), "\n");
}
fclose($fh);
return $numLines;
}
/**
* Return the country ISO code for an IP address.
*
* @return string
*/
public static function countryForIP($ip)
{
if (strpos($ip, ':') === false) {
$net = \App\IP4Net::getNet($ip);
} else {
$net = \App\IP6Net::getNet($ip);
}
return $net && $net->country ? $net->country : 'CH';
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
- /**
- * Shortcut to creating a progress bar of a particular format with a particular message.
- *
- * @param \Illuminate\Console\OutputStyle $output Console output object
- * @param int $count Number of progress steps
- * @param string $message The description
- *
- * @return \Symfony\Component\Console\Helper\ProgressBar
- */
- public static function createProgressBar($output, $count, $message = null)
- {
- $bar = $output->createProgressBar($count);
-
- $bar->setFormat(
- '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% '
- );
-
- if ($message) {
- $bar->setMessage($message . " ...");
- }
-
- $bar->start();
-
- return $bar;
- }
-
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
*
* @return string
*/
public static function generatePassphrase()
{
if (\config('app.env') == 'production') {
throw new \Exception("Thou shall not pass!");
}
if (\config('app.passphrase')) {
return \config('app.passphrase');
}
$alphaLow = 'abcdefghijklmnopqrstuvwxyz';
$alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num = '0123456789';
$stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
$source = $alphaLow . $alphaUp . $num . $stdSpecial;
$result = '';
for ($x = 0; $x < 16; $x++) {
$result .= substr($source, rand(0, (strlen($source) - 1)), 1);
}
return $result;
}
/**
* Find an object that is the recipient for the specified address.
*
* @param string $address
*
* @return array
*/
public static function findObjectsByRecipientAddress($address)
{
$address = \App\Utils::normalizeAddress($address);
list($local, $domainName) = explode('@', $address);
$domain = \App\Domain::where('namespace', $domainName)->first();
if (!$domain) {
return [];
}
$user = \App\User::where('email', $address)->first();
if ($user) {
return [$user];
}
$userAliases = \App\UserAlias::where('alias', $address)->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
$userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
return [];
}
/**
* Retrieve the network ID and Type from a client address
*
* @param string $clientAddress The IPv4 or IPv6 address.
*
* @return array An array of ID and class or null and null.
*/
public static function getNetFromAddress($clientAddress)
{
if (strpos($clientAddress, ':') === false) {
$net = \App\IP4Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP4Net::class];
}
} else {
$net = \App\IP6Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP6Net::class];
}
}
return [null, null];
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
$lastaddrbin = hex2bin($lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Normalize an email address.
*
* This means to lowercase and strip components separated with recipient delimiters.
*
* @param ?string $address The address to normalize
* @param bool $asArray Return an array with local and domain part
*
* @return string|array Normalized email address as string or array
*/
public static function normalizeAddress(?string $address, bool $asArray = false)
{
if ($address === null || $address === '') {
return $asArray ? ['', ''] : '';
}
$address = \strtolower($address);
if (strpos($address, '@') === false) {
return $asArray ? [$address, ''] : $address;
}
list($local, $domain) = explode('@', $address);
if (strpos($local, '+') !== false) {
$local = explode('+', $local)[0];
}
return $asArray ? [$local, $domain] : "{$local}@{$domain}";
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
* @return integer
*/
public static function uuidInt(): int
{
$hex = Uuid::uuid4();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return Uuid::uuid4()->toString();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
* @param string $route Route/Path
* @param int|null $tenantId Current tenant
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route, $tenantId = null): string
{
$url = \App\Tenant::getConfig($tenantId, 'app.public_url');
if (!$url) {
$url = \App\Tenant::getConfig($tenantId, 'app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
} elseif ($req_domain == "reseller.$sys_domain") {
$env['jsapp'] = 'reseller.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$env['languages'] = \App\Http\Controllers\ContentController::locales();
$env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
/**
* Retrieve an exchange rate.
*
* @param string $sourceCurrency: Currency from which to convert
* @param string $targetCurrency: Currency to convert to
*
* @return float Exchange rate
*/
public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
{
if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
return 1.0;
}
$currencyFile = resource_path("exchangerates-$sourceCurrency.php");
//Attempt to find the reverse exchange rate, if we don't have the file for the source currency
if (!file_exists($currencyFile)) {
$rates = include resource_path("exchangerates-$targetCurrency.php");
if (!isset($rates[$sourceCurrency])) {
throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
}
return 1.0 / floatval($rates[$sourceCurrency]);
}
$rates = include $currencyFile;
if (!isset($rates[$targetCurrency])) {
throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
}
return floatval($rates[$targetCurrency]);
}
}
diff --git a/src/tests/Feature/Console/Data/Import/LdifTest.php b/src/tests/Feature/Console/Data/Import/LdifTest.php
new file mode 100644
index 00000000..7a29f91b
--- /dev/null
+++ b/src/tests/Feature/Console/Data/Import/LdifTest.php
@@ -0,0 +1,432 @@
+<?php
+
+namespace Tests\Feature\Console\Data\Import;
+
+use Tests\TestCase;
+
+class LdifTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('owner@kolab3.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('owner@kolab3.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test the command
+ */
+ public function testHandle(): void
+ {
+ $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(1, $code);
+
+ $this->assertStringNotContainsString("Importing", $output);
+ $this->assertStringNotContainsString("WARNING", $output);
+ $this->assertStringContainsString(
+ "ERROR cn=error,ou=groups,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute",
+ $output
+ );
+ $this->assertStringContainsString(
+ "ERROR cn=error,ou=resources,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute",
+ $output
+ );
+
+ $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com --force");
+ $output = trim(\Artisan::output());
+
+ $this->assertSame(0, $code);
+ $this->assertStringContainsString("Importing domains... DONE", $output);
+ $this->assertStringContainsString("Importing users... DONE", $output);
+ $this->assertStringContainsString("Importing resources... DONE", $output);
+ $this->assertStringContainsString("Importing shared folders... DONE", $output);
+ $this->assertStringContainsString("Importing groups... DONE", $output);
+ $this->assertStringNotContainsString("ERROR", $output);
+ $this->assertStringContainsString(
+ "WARNING cn=unknowndomain,ou=groups,ou=kolab3.org,dc=hosted,dc=com: Domain not found",
+ $output
+ );
+
+ $owner = \App\User::where('email', 'owner@kolab3.com')->first();
+
+ $this->assertNull($owner->password);
+ $this->assertSame(
+ '{SSHA512}g74+SECTLsM1x0aYkSrTG9sOFzEp8wjCflhshr2DjE7mi1G3iNb4ClH3ljorPRlTgZ105PsQGEpNtNr+XRjigg==',
+ $owner->password_ldap
+ );
+
+ // User settings
+ $this->assertSame('Aleksander', $owner->getSetting('first_name'));
+ $this->assertSame('Machniak', $owner->getSetting('last_name'));
+ $this->assertSame('123456789', $owner->getSetting('phone'));
+ $this->assertSame('external@gmail.com', $owner->getSetting('external_email'));
+ $this->assertSame('Organization AG', $owner->getSetting('organization'));
+
+ // User aliases
+ $aliases = $owner->aliases()->orderBy('alias')->pluck('alias')->all();
+ $this->assertSame(['alias@kolab3-alias.com', 'alias@kolab3.com'], $aliases);
+
+ // Wallet, entitlements
+ $wallet = $owner->wallets->first();
+
+ $this->assertEntitlements($owner, [
+ 'groupware',
+ 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage',
+ ]);
+
+ // Users
+ $this->assertSame(2, $owner->users(false)->count());
+ $user = $owner->users(false)->where('email', 'user@kolab3.com')->first();
+
+ // User settings
+ $this->assertSame('Jane', $user->getSetting('first_name'));
+ $this->assertSame('Doe', $user->getSetting('last_name'));
+ $this->assertSame('1234567890', $user->getSetting('phone'));
+ $this->assertSame('ext@gmail.com', $user->getSetting('external_email'));
+ $this->assertSame('Org AG', $user->getSetting('organization'));
+
+ // User aliases
+ $aliases = $user->aliases()->orderBy('alias')->pluck('alias')->all();
+ $this->assertSame(['alias2@kolab3.com'], $aliases);
+
+ $this->assertEntitlements($user, [
+ 'groupware',
+ 'mailbox',
+ 'storage', 'storage', 'storage', 'storage', 'storage',
+ ]);
+
+ // Domains
+ $domains = $owner->domains(false, false)->orderBy('namespace')->get();
+
+ $this->assertCount(2, $domains);
+ $this->assertSame('kolab3-alias.com', $domains[0]->namespace);
+ $this->assertSame('kolab3.com', $domains[1]->namespace);
+ $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[0]->type);
+ $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[1]->type);
+
+ $this->assertEntitlements($domains[0], ['domain-hosting']);
+ $this->assertEntitlements($domains[1], ['domain-hosting']);
+
+ // Shared folders
+ $folders = $owner->sharedFolders(false)->orderBy('email')->get();
+
+ $this->assertCount(2, $folders);
+ $this->assertMatchesRegularExpression('/^event-[0-9]+@kolab3\.com$/', $folders[0]->email);
+ $this->assertMatchesRegularExpression('/^mail-[0-9]+@kolab3\.com$/', $folders[1]->email);
+ $this->assertSame('Folder2', $folders[0]->name);
+ $this->assertSame('Folder1', $folders[1]->name);
+ $this->assertSame('event', $folders[0]->type);
+ $this->assertSame('mail', $folders[1]->type);
+ $this->assertSame('["anyone, read-only"]', $folders[0]->getSetting('acl'));
+ $this->assertSame('shared/Folder2@kolab3.com', $folders[0]->getSetting('folder'));
+ $this->assertSame('["anyone, read-write","owner@kolab3.com, full"]', $folders[1]->getSetting('acl'));
+ $this->assertSame('shared/Folder1@kolab3.com', $folders[1]->getSetting('folder'));
+
+ // Groups
+ $groups = $owner->groups(false)->orderBy('email')->get();
+
+ $this->assertCount(1, $groups);
+ $this->assertSame('Group', $groups[0]->name);
+ $this->assertSame('group@kolab3.com', $groups[0]->email);
+ $this->assertSame(['owner@kolab3.com', 'user@kolab3.com'], $groups[0]->members);
+ $this->assertSame('["sender@gmail.com","-"]', $groups[0]->getSetting('sender_policy'));
+
+ // Resources
+ $resources = $owner->resources(false)->orderBy('email')->get();
+
+ $this->assertCount(1, $resources);
+ $this->assertSame('Resource', $resources[0]->name);
+ $this->assertMatchesRegularExpression('/^resource-[0-9]+@kolab3\.com$/', $resources[0]->email);
+ $this->assertSame('shared/Resource@kolab3.com', $resources[0]->getSetting('folder'));
+ $this->assertSame('manual:user@kolab3.com', $resources[0]->getSetting('invitation_policy'));
+ }
+
+ /**
+ * Test parseACL() method
+ */
+ public function testParseACL(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $result = $this->invokeMethod($command, 'parseACL', [[]]);
+ $this->assertSame([], $result);
+
+ $acl = [
+ 'anyone, read-write',
+ 'read-only@kolab3.com, read-only',
+ 'read-only@kolab3.com, read',
+ 'full@kolab3.com,full',
+ 'lrswipkxtecdn@kolab3.com, lrswipkxtecdn', // full
+ 'lrs@kolab3.com, lrs', // read-only
+ 'lrswitedn@kolab3.com, lrswitedn', // read-write
+ // unsupported:
+ 'anonymous, read-only',
+ 'group:test, lrs',
+ 'test@kolab3.com, lrspkxtdn',
+ ];
+
+ $expected = [
+ 'anyone, read-write',
+ 'read-only@kolab3.com, read-only',
+ 'read-only@kolab3.com, read-only',
+ 'full@kolab3.com, full',
+ 'lrswipkxtecdn@kolab3.com, full',
+ 'lrs@kolab3.com, read-only',
+ 'lrswitedn@kolab3.com, read-write',
+ ];
+
+ $result = $this->invokeMethod($command, 'parseACL', [$acl]);
+ $this->assertSame($expected, $result);
+ }
+
+ /**
+ * Test parseInvitationPolicy() method
+ */
+ public function testParseInvitationPolicy(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $result = $this->invokeMethod($command, 'parseInvitationPolicy', [[]]);
+ $this->assertSame(null, $result);
+
+ $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['UNKNOWN']]);
+ $this->assertSame(null, $result);
+
+ $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT']]);
+ $this->assertSame(null, $result);
+
+ $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_MANUAL']]);
+ $this->assertSame('manual', $result);
+
+ $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_REJECT']]);
+ $this->assertSame('reject', $result);
+
+ $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT_AND_NOTIFY', 'ACT_REJECT']]);
+ $this->assertSame(null, $result);
+ }
+
+ /**
+ * Test parseSenderPolicy() method
+ */
+ public function testParseSenderPolicy(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $result = $this->invokeMethod($command, 'parseSenderPolicy', [[]]);
+ $this->assertSame([], $result);
+
+ $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test']]);
+ $this->assertSame(['test', '-'], $result);
+
+ $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test', '-test2', 'test3', '']]);
+ $this->assertSame(['test', 'test3', '-'], $result);
+ }
+
+ /**
+ * Test parseLDAPDomain() method
+ */
+ public function testParseLDAPDomain(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $entry = [];
+ $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'associatedDomain' attribute", $result[1]);
+
+ $entry = ['associateddomain' => 'test.com'];
+ $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]);
+ $this->assertSame(['namespace' => 'test.com'], $result[0]);
+ $this->assertSame(null, $result[1]);
+
+ $entry = ['associateddomain' => 'test.com', 'inetdomainstatus' => 'deleted'];
+ $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Domain deleted", $result[1]);
+ }
+
+ /**
+ * Test parseLDAPGroup() method
+ */
+ public function testParseLDAPGroup(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $entry = [];
+ $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'cn' attribute", $result[1]);
+
+ $entry = ['cn' => 'Test'];
+ $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'mail' attribute", $result[1]);
+
+ $entry = ['cn' => 'Test', 'mail' => 'test@domain.tld'];
+ $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'uniqueMember' attribute", $result[1]);
+
+ $entry = [
+ 'cn' => 'Test',
+ 'mail' => 'Test@domain.tld',
+ 'uniquemember' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com',
+ 'kolaballowsmtpsender' => ['sender1@gmail.com', 'sender2@gmail.com'],
+ ];
+
+ $expected = [
+ 'name' => 'Test',
+ 'email' => 'test@domain.tld',
+ 'members' => ['uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com'],
+ 'sender_policy' => ['sender1@gmail.com', 'sender2@gmail.com', '-'],
+ ];
+
+ $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]);
+ $this->assertSame($expected, $result[0]);
+ $this->assertSame(null, $result[1]);
+ }
+
+ /**
+ * Test parseLDAPResource() method
+ */
+ public function testParseLDAPResource(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $entry = [];
+ $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'cn' attribute", $result[1]);
+
+ $entry = ['cn' => 'Test'];
+ $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'mail' attribute", $result[1]);
+
+ $entry = [
+ 'cn' => 'Test',
+ 'mail' => 'Test@domain.tld',
+ 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com',
+ 'kolabtargetfolder' => 'Folder',
+ 'kolabinvitationpolicy' => 'ACT_REJECT'
+ ];
+
+ $expected = [
+ 'name' => 'Test',
+ 'email' => 'test@domain.tld',
+ 'folder' => 'Folder',
+ 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com',
+ 'invitation_policy' => 'reject',
+ ];
+
+ $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]);
+ $this->assertSame($expected, $result[0]);
+ $this->assertSame(null, $result[1]);
+ }
+
+ /**
+ * Test parseLDAPSharedFolder() method
+ */
+ public function testParseLDAPSharedFolder(): void
+ {
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+
+ $entry = [];
+ $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'cn' attribute", $result[1]);
+
+ $entry = ['cn' => 'Test'];
+ $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'mail' attribute", $result[1]);
+
+ $entry = [
+ 'cn' => 'Test',
+ 'mail' => 'Test@domain.tld',
+ 'kolabtargetfolder' => 'Folder',
+ 'kolabfoldertype' => 'event',
+ 'acl' => 'anyone, read-write',
+ ];
+
+ $expected = [
+ 'name' => 'Test',
+ 'email' => 'test@domain.tld',
+ 'type' => 'event',
+ 'folder' => 'Folder',
+ 'acl' => ['anyone, read-write'],
+ ];
+
+ $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]);
+ $this->assertSame($expected, $result[0]);
+ $this->assertSame(null, $result[1]);
+ }
+
+ /**
+ * Test parseLDAPUser() method
+ */
+ public function testParseLDAPUser(): void
+ {
+ // Note: If we do not initialize the command input we'll get an error
+ $args = [
+ 'file' => 'test.ldif',
+ 'owner' => 'test@domain.tld',
+ ];
+
+ $command = new \App\Console\Commands\Data\Import\LdifCommand();
+ $command->setInput(new \Symfony\Component\Console\Input\ArrayInput($args, $command->getDefinition()));
+
+ $entry = ['cn' => 'Test'];
+ $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]);
+ $this->assertSame([], $result[0]);
+ $this->assertSame("Missing 'mail' attribute", $result[1]);
+
+ $entry = [
+ 'dn' => 'user dn',
+ 'givenname' => 'Given',
+ 'mail' => 'Test@domain.tld',
+ 'sn' => 'Surname',
+ 'telephonenumber' => '123',
+ 'o' => 'Org',
+ 'mailalternateaddress' => 'test@ext.com',
+ 'alias' => ['test1@domain.tld', 'test2@domain.tld'],
+ 'userpassword' => 'pass',
+ 'mailquota' => '12345678',
+ ];
+
+ $expected = [
+ 'email' => 'test@domain.tld',
+ 'settings' => [
+ 'first_name' => 'Given',
+ 'last_name' => 'Surname',
+ 'phone' => '123',
+ 'external_email' => 'test@ext.com',
+ 'organization' => 'Org',
+ ],
+ 'aliases' => ['test1@domain.tld', 'test2@domain.tld'],
+ 'password' => 'pass',
+ 'quota' => '12345678',
+ ];
+
+ $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]);
+ $this->assertSame($expected, $result[0]);
+ $this->assertSame(null, $result[1]);
+ $this->assertSame($entry['dn'], $this->getObjectProperty($command, 'ownerDN'));
+ }
+}
diff --git a/src/tests/data/kolab3.ldif b/src/tests/data/kolab3.ldif
new file mode 100644
index 00000000..22a80fcc
--- /dev/null
+++ b/src/tests/data/kolab3.ldif
@@ -0,0 +1,119 @@
+dn: associateddomain=kolab3.com,ou=Domains,dc=hosted,dc=com
+objectClass: top
+objectClass: domainrelatedobject
+objectClass: inetdomain
+inetDomainBaseDN: ou=kolab3.com,dc=hosted,dc=com
+associatedDomain: kolab3.com
+associatedDomain: kolab3-alias.com
+
+dn: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+cn: Aleksander Machniak
+displayName: Machniak, Aleksander
+givenName: Aleksander
+sn: Machniak
+o: Organization AG
+l:: U2llbWlhbm93aWNlIMWabMSFc2tpZQ==
+mobile: 123456789
+c: PL
+objectClass: top
+objectClass: inetorgperson
+objectClass: kolabinetorgperson
+objectClass: organizationalperson
+objectClass: mailrecipient
+objectClass: country
+objectClass: person
+mail: owner@kolab3.com
+alias: alias@kolab3.com
+alias: alias@kolab3-alias.com
+mailAlternateAddress: external@gmail.com
+mailHost: imap.hosted.com
+mailQuota: 8388608
+uid: owner@kolab3.com
+userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ==
+nsUniqueID: 229dc10c-1b6a11f7-b7c1edc1-0e0f46c4
+createtimestamp: 20170407081419Z
+modifytimestamp: 20200915082359Z
+
+dn: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+cn: Jane Doe
+displayName: Doe, Jane
+givenName: Jane
+sn: Doe
+o: Org AG
+telephoneNumber: 1234567890
+objectClass: top
+objectClass: inetorgperson
+objectClass: kolabinetorgperson
+objectClass: organizationalperson
+objectClass: mailrecipient
+objectClass: country
+objectClass: person
+mail: user@kolab3.com
+alias: alias2@kolab3.com
+mailAlternateAddress: ext@gmail.com
+mailHost: imap.hosted.com
+mailQuota: 2097152
+uid: user@kolab3.com
+userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ==
+nsUniqueID: 229dc20c-1b6a11f7-b7c1edc1-0e0f46c4
+
+dn: cn=Group,ou=Groups,ou=kolab3.com,dc=hosted,dc=com
+cn: Group
+mail: group@kolab3.com
+objectClass: top
+objectClass: groupofuniquenames
+objectClass: kolabgroupofuniquenames
+uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+uniqueMember: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+kolabAllowSMTPRecipient: recipient@kolab.org
+kolabAllowSMTPSender: sender@gmail.com
+
+dn: cn=Error,ou=Groups,ou=kolab3.com,dc=hosted,dc=com
+cn: Error
+uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+
+dn: cn=UnknownDomain,ou=Groups,ou=kolab3.org,dc=hosted,dc=com
+cn: UnknownDomain
+mail: unknowndomain@kolab3.org
+objectClass: top
+objectClass: groupofuniquenames
+objectClass: kolabgroupofuniquenames
+uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+uniqueMember: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+
+dn: cn=Resource,ou=Resources,ou=kolab3.com,dc=hosted,dc=com
+cn: Resource
+mail: resource-car-resource@kolab3.com
+objectClass: top
+objectClass: kolabsharedfolder
+objectClass: kolabresource
+objectClass: mailrecipient
+owner: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com
+kolabAllowSMTPRecipient: recipient@kolab.org
+kolabAllowSMTPSender: sender@gmail.com
+kolabInvitationPolicy: ACT_MANUAL
+kolabTargetFolder: shared/Resource@kolab3.com
+
+dn: cn=Error,ou=Resources,ou=kolab3.com,dc=hosted,dc=com
+cn: Error
+
+dn: cn=Folder1,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com
+cn: Folder1
+objectClass: kolabsharedfolder
+objectClass: mailrecipient
+objectClass: top
+kolabFolderType: mail
+kolabTargetFolder: shared/Folder1@kolab3.com
+mail: folder1@kolab3.com
+acl: anyone, read-write
+acl: owner@kolab3.com, full
+
+dn: cn=Folder2,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com
+cn: Folder2
+objectClass: kolabsharedfolder
+objectClass: mailrecipient
+objectClass: top
+kolabFolderType: event
+kolabTargetFolder: shared/Folder2@kolab3.com
+mail: folder2@kolab3.com
+acl: anyone, read-only
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Jan 30, 9:24 PM (2 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426048
Default Alt Text
(122 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment