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 @@ 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 @@ '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 @@ '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 @@ +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 @@ 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 @@ 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 @@ 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 @@ 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 " 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 @@ 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 @@ +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