Page MenuHomePhorge

No OneTemporary

Size
122 KB
Referenced Files
None
Subscribers
None
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

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)

Event Timeline