diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php index 6e6e799f..051059c4 100644 --- a/src/app/Backends/PGP.php +++ b/src/app/Backends/PGP.php @@ -1,254 +1,254 @@ deleteDirectory($homedir); } else { Storage::disk('pgp')->delete(Storage::disk('pgp')->files($homedir)); foreach (Storage::disk('pgp')->files($homedir) as $subdir) { Storage::disk('pgp')->deleteDirectory($subdir); } } // Remove all files from the Enigma database // Note: This will cause existing files in the Roundcube filesystem // to be removed, but only if the user used the Enigma functionality Roundcube::enigmaCleanup($user->email); } /** * Generate a keypair. * This will also initialize the user GPG homedir content. * * @param \App\User $user User object * @param string $email Email address to use for the key * * @throws \Exception */ public static function keypairCreate(User $user, string $email): void { self::initGPG($user, true); if ($user->email === $email) { // Make sure the homedir is empty for a new user self::homedirCleanup($user); } $keygen = new \Crypt_GPG_KeyGenerator(self::$config); $key = $keygen // ->setPassphrase() // ->setExpirationDate(0) ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) - ->generateKey(null, $email); + ->generateKey('', $email); // Store the keypair in Roundcube Enigma storage self::dbSave(true); // Get the ASCII armored data of the public key $armor = self::$gpg->exportPublicKey((string) $key, true); // Register the public key in DNS self::keyRegister($email, $armor); // FIXME: Should we remove the files from the worker filesystem? // They are still in database and Roundcube hosts' filesystem } /** * Deleta a keypair from DNS and Enigma keyring. * * @param \App\User $user User object * @param string $email Email address of the key * * @throws \Exception */ public static function keyDelete(User $user, string $email): void { // Start with the DNS, it's more important self::keyUnregister($email); // Remove the whole Enigma keyring (if it's a delete user account) if ($user->email === $email) { self::homedirCleanup($user); } else { // TODO: remove only the alias key from Enigma keyring } } /** * List (public and private) keys from a user keyring. * * @param \App\User $user User object * * @returns \Crypt_GPG_Key[] List of keys * @throws \Exception */ public static function listKeys(User $user): array { self::initGPG($user); return self::$gpg->getKeys(''); } /** * Debug logging callback */ public static function logDebug($msg): void { \Log::debug("[GPG] $msg"); } /** * Register the key in the WOAT DNS system * * @param string $email Email address * @param string $key The ASCII-armored key content */ private static function keyRegister(string $email, string $key): void { list($local, $domain) = \App\Utils::normalizeAddress($email, true); DB::beginTransaction(); $domain = \App\PowerDNS\Domain::firstOrCreate([ 'name' => '_woat.' . $domain, ]); \App\PowerDNS\Record::create([ 'domain_id' => $domain->id, 'name' => sha1($local) . '.' . $domain->name, 'type' => 'TXT', 'content' => 'v=woat1,public_key=' . $key ]); DB::commit(); } /** * Remove the key from the WOAT DNS system * * @param string $email Email address */ private static function keyUnregister(string $email): void { list($local, $domain) = \App\Utils::normalizeAddress($email, true); $domain = \App\PowerDNS\Domain::where('name', '_woat.' . $domain)->first(); if ($domain) { $fqdn = sha1($local) . '.' . $domain->name; // For now we support only one WOAT key record $domain->records()->where('name', $fqdn)->delete(); } } /** * Prepare Crypt_GPG configuration */ private static function initConfig(User $user, $nosync = false): void { if (!empty(self::$config) && self::$config['email'] == $user->email) { return; } $debug = \config('app.debug'); $binary = \config('pgp.binary'); $agent = \config('pgp.agent'); $gpgconf = \config('pgp.gpgconf'); $dir = self::setHomedir($user); $options = [ 'email' => $user->email, // this one is not a Crypt_GPG option 'dir' => $dir, // this one is not a Crypt_GPG option 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir, 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null, ]; if ($binary) { $options['binary'] = $binary; } if ($agent) { $options['agent'] = $agent; } if ($gpgconf) { $options['gpgconf'] = $gpgconf; } self::$config = $options; // Sync the homedir directory content with the Enigma storage if (!$nosync) { self::dbSync(); } } /** * Initialize Crypt_GPG */ private static function initGPG(User $user, $nosync = false): void { self::initConfig($user, $nosync); self::$gpg = new \Crypt_GPG(self::$config); } /** * Prepare a homedir for the user */ private static function setHomedir(User $user): string { // Create a subfolder using two first digits of the user ID $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email; Storage::disk('pgp')->makeDirectory($dir); return $dir; } /** * Synchronize keys database of a user */ private static function dbSync(): void { Roundcube::enigmaSync(self::$config['email'], self::$config['dir']); } /** * Save the keys database */ private static function dbSave($is_empty = false): void { Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $is_empty); } } diff --git a/src/app/Backends/Roundcube.php b/src/app/Backends/Roundcube.php index 8d35942a..6ed0e2e3 100644 --- a/src/app/Backends/Roundcube.php +++ b/src/app/Backends/Roundcube.php @@ -1,282 +1,282 @@ table(self::FILESTORE_TABLE) ->where('user_id', self::userId($email)) ->where('context', 'enigma') ->delete(); } /** * List all files from the Enigma filestore. * * @param string $email User email address * * @return array List of Enigma filestore records */ public static function enigmaList(string $email): array { return self::dbh()->table(self::FILESTORE_TABLE) ->where('user_id', self::userId($email)) ->where('context', 'enigma') ->orderBy('filename') ->get() ->all(); } /** * Synchronize Enigma filestore from/to specified directory * * @param string $email User email address * @param string $homedir Directory location */ public static function enigmaSync(string $email, string $homedir): void { $db = self::dbh(); $debug = \config('app.debug'); $user_id = self::userId($email); $root = \config('filesystems.disks.pgp.root'); $fs = Storage::disk('pgp'); $files = []; $result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') ->where('user_id', $user_id) ->where('context', 'enigma') ->get(); foreach ($result as $record) { $file = $homedir . '/' . $record->filename; $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; $files[] = $record->filename; if ($mtime < $record->mtime) { $file_id = $record->file_id; $record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime') ->where('file_id', $file_id) ->first(); $data = $record ? base64_decode($record->data) : false; if ($data === false) { \Log::error("Failed to sync $file ({$file_id}). Decode error."); continue; } if ($fs->put($file, $data, true)) { // Note: Laravel Filesystem API does not provide touch method touch("$root/$file", $record->mtime); if ($debug) { \Log::debug("[SYNC] Fetched file: $file"); } } } } // Remove files not in database foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) { $file = $homedir . '/' . $file; if ($fs->delete($file)) { if ($debug) { \Log::debug("[SYNC] Removed file: $file"); } } } // No records found, do initial sync if already have the keyring if (empty($file)) { - self::enigmaSave(true, $homedir); + self::enigmaSave($email, $homedir); } } /** * Save the keys database * * @param string $email User email address * @param string $homedir Directory location * @param bool $is_empty Set to Tre if it is a initial save */ public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void { $db = self::dbh(); $debug = \config('app.debug'); $user_id = self::userId($email); $fs = Storage::disk('pgp'); $records = []; if (!$is_empty) { $records = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime') ->where('user_id', $user_id) ->where('context', 'enigma') ->get() ->keyBy('filename') ->all(); } foreach (self::enigmaFilesList($homedir) as $filename) { $file = $homedir . '/' . $filename; $mtime = $fs->exists($file) ? $fs->lastModified($file) : 0; $existing = !empty($records[$filename]) ? $records[$filename] : null; unset($records[$filename]); if ($mtime && (empty($existing) || $mtime > $existing->mtime)) { $data = base64_encode($fs->get($file)); /* if (empty($maxsize)) { $maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000; } if (strlen($data) > $maxsize) { \Log::error("Failed to save $file. Size exceeds max_allowed_packet."); continue; } */ $result = $db->table(self::FILESTORE_TABLE)->updateOrInsert( ['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename], ['mtime' => $mtime, 'data' => $data] ); if ($debug) { \Log::debug("[SYNC] Pushed file: $file"); } } } // Delete removed files from database foreach (array_keys($records) as $filename) { $file = $homedir . '/' . $filename; $result = $db->table(self::FILESTORE_TABLE) ->where('user_id', $user_id) ->where('context', 'enigma') ->where('filename', $filename) ->delete(); if ($debug) { \Log::debug("[SYNC] Removed file: $file"); } } } /** * Delete a Roundcube user. * * @param string $email User email address */ public static function deleteUser(string $email): void { $db = self::dbh(); $db->table(self::USERS_TABLE)->where('username', \strtolower($email))->delete(); } /** * Find the Roundcube user identifier for the specified user. * * @param string $email User email address * @param bool $create Make sure the user record exists * * @returns ?int Roundcube user identifier */ public static function userId(string $email, bool $create = true): ?int { $db = self::dbh(); $user = $db->table(self::USERS_TABLE)->select('user_id') ->where('username', \strtolower($email)) ->first(); // Create a user record, without it we can't use the Roundcube storage if (empty($user)) { if (!$create) { return null; } $uri = \parse_url(\config('imap.uri')); $user_id = (int) $db->table(self::USERS_TABLE)->insertGetId( [ 'username' => $email, 'mail_host' => $uri['host'], 'created' => now()->toDateTimeString(), ], 'user_id' ); $username = \App\User::where('email', $email)->first()->name(); $db->table(self::IDENTITIES_TABLE)->insert([ 'user_id' => $user_id, 'email' => $email, 'name' => $username, 'changed' => now()->toDateTimeString(), 'standard' => 1, ]); return $user_id; } return (int) $user->user_id; } /** * Returns list of Enigma user homedir files to backup/sync */ private static function enigmaFilesList(string $homedir) { $files = []; $fs = Storage::disk('pgp'); foreach (self::$enigma_files as $file) { if ($fs->exists($homedir . '/' . $file)) { $files[] = $file; } } foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) { if (preg_match('/\.key$/', $file)) { $files[] = substr($file, strlen($homedir . '/')); } } return $files; } } diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php index 7878a51e..9de9eb20 100644 --- a/src/app/CompanionApp.php +++ b/src/app/CompanionApp.php @@ -1,115 +1,117 @@ The attributes that are mass assignable */ protected $fillable = [ 'name', 'user_id', 'device_id', 'notification_token', 'mfa_enabled', ]; /** - * Send a notification via firebase. - * - * @param array $deviceIds A list of device id's to send the notification to - * @param array $data The data to include in the notification. - * - * @throws \Exception on notification failure - * @return bool true if a notification has been sent - */ + * Send a notification via firebase. + * + * @param array $deviceIds A list of device id's to send the notification to + * @param array $data The data to include in the notification. + * + * @throws \Exception on notification failure + * @return bool true if a notification has been sent + */ private static function pushFirebaseNotification($deviceIds, $data): bool { \Log::debug("sending notification to " . var_export($deviceIds, true)); $apiKey = \config('firebase.api_key'); $client = new \GuzzleHttp\Client( [ 'verify' => \config('firebase.api_verify_tls') ] ); $response = $client->request( 'POST', \config('firebase.api_url'), [ 'headers' => [ 'Authorization' => "key={$apiKey}", ], 'json' => [ 'registration_ids' => $deviceIds, 'data' => $data ] ] ); if ($response->getStatusCode() != 200) { throw new \Exception('FCM Send Error: ' . $response->getStatusCode()); } return true; } /** - * Send a notification to a user. - * - * @throws \Exception on notification failure - * @return bool true if a notification has been sent - */ + * Send a notification to a user. + * + * @throws \Exception on notification failure + * @return bool true if a notification has been sent + */ public static function notifyUser($userId, $data): bool { $notificationTokens = CompanionApp::where('user_id', $userId) ->where('mfa_enabled', true) ->pluck('notification_token') ->all(); if (empty($notificationTokens)) { \Log::debug("There is no 2fa device to notify."); return false; } self::pushFirebaseNotification($notificationTokens, $data); return true; } /** * Returns whether this companion app is paired with a device. * * @return bool */ public function isPaired(): bool { return !empty($this->device_id); } /** * The PassportClient of this CompanionApp * * @return \App\Auth\PassportClient|null */ public function passportClient() { return \App\Auth\PassportClient::find($this->oauth_client_id); } /** * Set the PassportClient of this CompanionApp + * + * @param \Laravel\Passport\Client $client The client object */ - public function setPassportClient(\App\Auth\PassportClient $client) + public function setPassportClient(\Laravel\Passport\Client $client) { return $this->oauth_client_id = $client->id; } } diff --git a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php index 4ab55fa7..15df0adf 100644 --- a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php +++ b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php @@ -1,208 +1,208 @@ '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 = $this->createProgressBar($numLines, "Importing IPv4 Networks from {$rir}-{$today}"); $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] == "*" || $items[1] == "" || $items[1] == "ZZ") { continue; } if ($items[2] != "ipv4") { continue; } if ($items[5] == "00000000") { $items[5] = "19700102"; } $bar->advance(); - $mask = 32 - log($items[4], 2); + $mask = 32 - log((float) $items[4], 2); $broadcast = long2ip((ip2long($items[3]) + 2 ** (32 - $mask)) - 1); $net = \App\IP4Net::where( [ 'net_number' => inet_pton($items[3]), 'net_mask' => $mask, 'net_broadcast' => inet_pton($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' => inet_pton($items[3]), 'net_mask' => $mask, 'net_broadcast' => inet_pton($broadcast), '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/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php index a24e6ea3..4320894f 100644 --- a/src/app/Console/Commands/Data/Import/LdifCommand.php +++ b/src/app/Console/Commands/Data/Import/LdifCommand.php @@ -1,1010 +1,1010 @@ 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) { // @phpstan-ignore-next-line 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->domainName = $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->domainName = $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); } // Import aliases if (!empty($data->aliases)) { $this->setObjectAliases($folder, $data->aliases); } } $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->setObjectAliases($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']; + $quota = (int) (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->setObjectAliases($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')); } if (!empty($entry['alias'])) { $result['aliases'] = $this->attrArrayValue($entry, 'alias'); } } 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 for an object */ protected function setObjectAliases($object, 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), [$object->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)) { $object->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/Domain/SetStatusCommand.php b/src/app/Console/Commands/Domain/SetStatusCommand.php index fff47f93..a51d1a83 100644 --- a/src/app/Console/Commands/Domain/SetStatusCommand.php +++ b/src/app/Console/Commands/Domain/SetStatusCommand.php @@ -1,45 +1,45 @@ getDomain($this->argument('domain')); if (!$domain) { $this->error("Domain not found."); return 1; } Queue::fake(); // ignore LDAP for now $domain->status = (int) $this->argument('status'); $domain->save(); - $this->info($domain->status); + $this->info((string) $domain->status); } } diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php index 4b1cbc64..ea4136c9 100644 --- a/src/app/Console/Commands/Group/CreateCommand.php +++ b/src/app/Console/Commands/Group/CreateCommand.php @@ -1,89 +1,89 @@ argument('email'); $members = $this->option('member'); list($local, $domainName) = explode('@', $email, 2); $domain = $this->getDomain($domainName); if (!$domain) { $this->error("No such domain {$domainName}."); return 1; } if ($domain->isPublic()) { $this->error("Domain {$domainName} is public."); return 1; } $owner = $domain->wallet()->owner; // Validate members addresses foreach ($members as $i => $member) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $this->error("{$member}: $error"); return 1; } if (\strtolower($member) === \strtolower($email)) { $this->error("{$member}: Cannot be the same as the group address."); return 1; } } // Validate group email address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $this->error("{$email}: {$error}"); return 1; } DB::beginTransaction(); // Create the group $group = new Group(); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); - $this->info($group->id); + $this->info((string) $group->id); } } diff --git a/src/app/Console/Commands/SharedFolder/CreateCommand.php b/src/app/Console/Commands/SharedFolder/CreateCommand.php index 9107a7d5..bf4dd97c 100644 --- a/src/app/Console/Commands/SharedFolder/CreateCommand.php +++ b/src/app/Console/Commands/SharedFolder/CreateCommand.php @@ -1,101 +1,101 @@ argument('domain'); $name = $this->argument('name'); $type = $this->option('type'); $acl = $this->option('acl'); if (empty($type)) { $type = 'mail'; } $domain = $this->getDomain($domainName); if (!$domain) { $this->error("No such domain {$domainName}."); return 1; } if ($domain->isPublic()) { $this->error("Domain {$domainName} is public."); return 1; } $owner = $domain->wallet()->owner; // Validate folder name and type $rules = [ 'name' => ['required', 'string', new SharedFolderName($owner, $domain->namespace)], 'type' => ['required', 'string', new SharedFolderType()] ]; $v = Validator::make(['name' => $name, 'type' => $type], $rules); if ($v->fails()) { $this->error($v->errors()->all()[0]); return 1; } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); $folder->name = $name; $folder->type = $type; $folder->domainName = $domainName; $folder->save(); $folder->assignToWallet($owner->wallets->first()); if (!empty($acl)) { $errors = $folder->setConfig(['acl' => $acl]); if (!empty($errors)) { $this->error("Invalid --acl entry."); DB::rollBack(); return 1; } } DB::commit(); - $this->info($folder->id); + $this->info((string) $folder->id); } } diff --git a/src/app/Console/Commands/Wallet/GetBalanceCommand.php b/src/app/Console/Commands/Wallet/GetBalanceCommand.php index e459d604..a14a666e 100644 --- a/src/app/Console/Commands/Wallet/GetBalanceCommand.php +++ b/src/app/Console/Commands/Wallet/GetBalanceCommand.php @@ -1,39 +1,39 @@ getWallet($this->argument('wallet')); if (!$wallet) { $this->error("Wallet not found."); return 1; } - $this->info($wallet->balance); + $this->info((string) $wallet->balance); } } diff --git a/src/app/Console/Commands/Wallet/GetDiscountCommand.php b/src/app/Console/Commands/Wallet/GetDiscountCommand.php index 52378f67..2a4d69a4 100644 --- a/src/app/Console/Commands/Wallet/GetDiscountCommand.php +++ b/src/app/Console/Commands/Wallet/GetDiscountCommand.php @@ -1,44 +1,44 @@ getWallet($this->argument('wallet')); if (!$wallet) { $this->error("Wallet not found."); return 1; } if (!$wallet->discount) { $this->info("No discount on this wallet."); return 0; } - $this->info($wallet->discount->discount); + $this->info((string) $wallet->discount->discount); } } diff --git a/src/app/Console/Commands/Wallet/MandateCommand.php b/src/app/Console/Commands/Wallet/MandateCommand.php index 0031a5ec..af7d7888 100644 --- a/src/app/Console/Commands/Wallet/MandateCommand.php +++ b/src/app/Console/Commands/Wallet/MandateCommand.php @@ -1,68 +1,68 @@ getWallet($this->argument('wallet')); if (!$wallet) { $this->error("Wallet not found."); return 1; } $mandate = PaymentsController::walletMandate($wallet); if (!empty($mandate['id'])) { $disabled = $mandate['isDisabled'] ? 'Yes' : 'No'; $status = 'invalid'; if ($mandate['isPending']) { $status = 'pending'; } elseif ($mandate['isValid']) { $status = 'valid'; } if ($this->option('disable') && $disabled == 'No') { - $wallet->setSetting('mandate_disabled', 1); + $wallet->setSetting('mandate_disabled', '1'); $disabled = 'Yes'; } elseif ($this->option('enable') && $disabled == 'Yes') { $wallet->setSetting('mandate_disabled', null); $disabled = 'No'; } $this->info("Auto-payment: {$mandate['method']}"); $this->info(" id: {$mandate['id']}"); $this->info(" status: {$status}"); $this->info(" amount: {$mandate['amount']} {$wallet->currency}"); $this->info(" min-balance: {$mandate['balance']} {$wallet->currency}"); $this->info(" disabled: $disabled"); } else { $this->info("Auto-payment: none"); } } } diff --git a/src/app/Console/ObjectListCommand.php b/src/app/Console/ObjectListCommand.php index 51008c8f..07491c7b 100644 --- a/src/app/Console/ObjectListCommand.php +++ b/src/app/Console/ObjectListCommand.php @@ -1,114 +1,114 @@ description = "List all {$this->objectName} objects"; $this->signature = $this->commandPrefix ? $this->commandPrefix . ":" : ""; if (!empty($this->objectNamePlural)) { $this->signature .= "{$this->objectNamePlural}"; } else { $this->signature .= "{$this->objectName}s"; } $classes = class_uses_recursive($this->objectClass); if (in_array(SoftDeletes::class, $classes)) { $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; } $this->signature .= " {--attr=* : Attributes other than the primary unique key to include}" . "{--filter=* : Additional filter(s) or a raw SQL WHERE clause}"; parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { $classes = class_uses_recursive($this->objectClass); // @phpstan-ignore-next-line if (in_array(SoftDeletes::class, $classes) && $this->option('with-deleted')) { $objects = $this->objectClass::withTrashed(); } else { $objects = new $this->objectClass(); } foreach ($this->option('filter') as $filter) { $objects = $this->applyFilter($objects, $filter); } foreach ($objects->cursor() as $object) { if ($object->deleted_at) { $this->info("{$this->toString($object)} (deleted at {$object->deleted_at}"); } else { $this->info("{$this->toString($object)}"); } } } /** * Apply pre-configured filter or raw WHERE clause to the main query. * * @param object $query Query builder * @param string $filter Pre-defined filter identifier or raw SQL WHERE clause * * @return object Query builder */ public function applyFilter($query, string $filter) { // Get objects marked as deleted, i.e. --filter=TRASHED // Note: For use with --with-deleted option if (strtolower($filter) === 'trashed') { return $query->whereNotNull('deleted_at'); } // Get objects with specified status, e.g. --filter=STATUS:SUSPENDED if (preg_match('/^status:([a-z]+)$/i', $filter, $matches)) { $status = strtoupper($matches[1]); $const = "{$this->objectClass}::STATUS_{$status}"; if (defined($const)) { return $query->where('status', '&', constant($const)); } throw new \Exception("Unknown status in --filter={$filter}"); } // Get objects older/younger than specified time, e.g. --filter=MIN-AGE:1Y if (preg_match('/^(min|max)-age:([0-9]+)([mdy])$/i', $filter, $matches)) { $operator = strtolower($matches[1]) == 'min' ? '<=' : '>='; - $count = $matches[2]; + $count = (int) $matches[2]; $period = strtolower($matches[3]); $date = \Carbon\Carbon::now(); if ($period == 'y') { $date->subYearsWithoutOverflow($count); } elseif ($period == 'm') { $date->subMonthsWithoutOverflow($count); } else { $date->subDays($count); } return $query->where('created_at', $operator, $date); } return $query->whereRaw($filter); } } diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php index 04326e2e..14a2053a 100644 --- a/src/app/Documents/Receipt.php +++ b/src/app/Documents/Receipt.php @@ -1,277 +1,277 @@ wallet = $wallet; $this->year = $year; $this->month = $month; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'pdf') * * @return string HTML or PDF output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(['currency' => 'CHF']); $wallet->id = \App\Utils::uuidStr(); $wallet->owner = new User(['id' => 123456789]); - $receipt = new self($wallet, date('Y'), date('n')); + $receipt = new self($wallet, (int) date('Y'), (int) date('n')); self::$fakeMode = true; if ($type == 'pdf') { return $receipt->pdfOutput(); } elseif ($type !== 'html') { throw new \Exception("Unsupported output format"); } return $receipt->htmlOutput(); } /** * Render the receipt in HTML format. * * @return string HTML content */ public function htmlOutput(): string { return $this->build()->render(); } /** * Render the receipt in PDF format. * * @return string PDF content */ public function pdfOutput(): string { // Parse ther HTML template $html = $this->build()->render(); // Link fonts from public/fonts to storage/fonts so DomPdf can find them if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) { symlink( public_path('fonts/Roboto-Regular.ttf'), storage_path('fonts/Roboto-Regular.ttf') ); symlink( public_path('fonts/Roboto-Bold.ttf'), storage_path('fonts/Roboto-Bold.ttf') ); } // Fix font and image paths $html = str_replace('url(/fonts/', 'url(fonts/', $html); $html = str_replace('src="/', 'src="', $html); // TODO: The output file is about ~200KB, we could probably slim it down // by using separate font files with small subset of languages when // there are no Unicode characters used, e.g. only ASCII or Latin. // Load PDF generator $pdf = Pdf::loadHTML($html)->setPaper('a4', 'portrait'); return $pdf->output(); } /** * Build the document * * @return \Illuminate\View\View The template object */ protected function build() { $appName = \config('app.name'); $start = Carbon::create($this->year, $this->month, 1, 0, 0, 0); $end = $start->copy()->endOfMonth(); $month = \trans('documents.month' . intval($this->month)); $title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]); $company = $this->companyData(); if (self::$fakeMode) { $customer = [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, 'customer' => 'Freddie Krüger
7252 Westminster Lane
Forest Hills, NY 11375', ]; $items = collect([ (object) [ 'amount' => 1234, 'updated_at' => $start->copy()->next(Carbon::MONDAY), ], (object) [ 'amount' => 10000, 'updated_at' => $start->copy()->next()->next(), ], (object) [ 'amount' => 1234, 'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY), ], (object) [ 'amount' => 99, 'updated_at' => $start->copy()->next()->next()->next(), ], ]); $items = $items->map(function ($payment) { $payment->vatRate = new \App\VatRate(); $payment->vatRate->rate = 7.7; $payment->credit_amount = $payment->amount + round($payment->amount * $payment->vatRate->rate / 100); return $payment; }); } else { $customer = $this->customerData(); $items = $this->wallet->payments() ->where('status', Payment::STATUS_PAID) ->where('updated_at', '>=', $start) ->where('updated_at', '<', $end) ->where('amount', '<>', 0) ->orderBy('updated_at') ->get(); } $vatRate = 0; $totalVat = 0; $total = 0; // excluding VAT $items = $items->map(function ($item) use (&$total, &$totalVat, &$vatRate, $appName) { $amount = $item->amount; if ($item->vatRate && $item->vatRate->rate > 0) { $vat = round($item->credit_amount * $item->vatRate->rate / 100); $amount -= $vat; $totalVat += $vat; $vatRate = $item->vatRate->rate; // TODO: Multiple rates } $total += $amount; $type = $item->type ?? null; if ($type == Payment::TYPE_REFUND) { $description = \trans('documents.receipt-refund'); } elseif ($type == Payment::TYPE_CHARGEBACK) { $description = \trans('documents.receipt-chargeback'); } else { $description = \trans('documents.receipt-item-desc', ['site' => $appName]); } return [ 'amount' => $this->wallet->money($amount), 'description' => $description, 'date' => $item->updated_at->toDateString(), ]; }); // Load the template $view = view('documents.receipt') ->with([ 'site' => $appName, 'title' => $title, 'company' => $company, 'customer' => $customer, 'items' => $items, 'subTotal' => $this->wallet->money($total), 'total' => $this->wallet->money($total + $totalVat), 'totalVat' => $this->wallet->money($totalVat), 'vatRate' => preg_replace('/(\.00|0|\.)$/', '', sprintf('%.2F', $vatRate)), 'vat' => $vatRate > 0, ]); return $view; } /** * Prepare customer data for the template * * @return array Customer data for the template */ protected function customerData(): array { $user = $this->wallet->owner; $name = $user->name(); $settings = $user->getSettings(['organization', 'billing_address']); $customer = trim(($settings['organization'] ?: $name) . "\n" . $settings['billing_address']); $customer = str_replace("\n", '
', htmlentities($customer)); return [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, 'customer' => $customer, ]; } /** * Prepare company data for the template * * @return array Company data for the template */ protected function companyData(): array { $header = \config('app.company.name') . "\n" . \config('app.company.address'); $header = str_replace("\n", '
', htmlentities($header)); $footerLineLength = 110; $footer = \config('app.company.details'); $contact = \config('app.company.email'); $logo = \config('app.company.logo'); $theme = \config('app.theme'); if ($contact) { $length = strlen($footer) + strlen($contact) + 3; $contact = htmlentities($contact); $footer .= ($length > $footerLineLength ? "\n" : ' | ') . sprintf('%s', $contact, $contact); } if ($logo && strpos($logo, '/') === false) { $logo = "/themes/$theme/images/$logo"; } return [ 'logo' => $logo ? "" : '', 'header' => $header, 'footer' => $footer, ]; } } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index 7132716b..6f23a6c8 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,197 +1,197 @@ user(); if (!empty(request()->input('refresh'))) { return $this->refreshAndRespond(request(), $user); } $response = V4\UsersController::userResponse($user); return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object * @param string $password Plain text password * @param string|null $secondFactor Second factor code if available */ public static function logonResponse(User $user, string $password, string $secondFactor = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'username' => $user->email, 'password' => $password, 'grant_type' => 'password', 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), 'scope' => 'api', 'secondfactor' => $secondFactor ]); $proxyRequest->headers->set('X-Client-IP', request()->ip()); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get an oauth token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:3', 'password' => 'required|min:1', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $user = \App\User::where('email', $request->email)->first(); if (!$user) { return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } return self::logonResponse($user, $request->password, $request->secondfactor); } /** * Get the user (geo) location * * @return \Illuminate\Http\JsonResponse */ public function location() { $ip = request()->ip(); $response = [ 'ipAddress' => $ip, 'countryCode' => \App\Utils::countryForIP($ip, ''), ]; return response()->json($response); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $tokenId = Auth::user()->token()->id; $tokenRepository = app(TokenRepository::class); $refreshTokenRepository = app(RefreshTokenRepository::class); // Revoke an access token... $tokenRepository->revokeAccessToken($tokenId); // Revoke all of the token's refresh tokens... $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId); return response()->json([ 'status' => 'success', 'message' => self::trans('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh(Request $request) { return self::refreshAndRespond($request); } /** * Refresh the token and respond with it. * * @param \Illuminate\Http\Request $request The API request. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function refreshAndRespond(Request $request, $user = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'grant_type' => 'refresh_token', 'refresh_token' => $request->refresh_token, 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), ]); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get the token array structure. * - * @param \Illuminate\Http\JsonResponse $tokenResponse The response containing the token. - * @param ?\App\User $user The user being authenticated + * @param \Symfony\Component\HttpFoundation\Response $tokenResponse The response containing the token. + * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function respondWithToken($tokenResponse, $user = null) { $data = json_decode($tokenResponse->getContent()); if ($tokenResponse->getStatusCode() != 200) { if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) { $errors = ['secondfactor' => $data->error_description]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } if ($user) { $response = V4\UsersController::userResponse($user); } else { $response = []; } $response['status'] = 'success'; $response['access_token'] = $data->access_token; $response['refresh_token'] = $data->refresh_token; $response['token_type'] = 'bearer'; $response['expires_in'] = $data->expires_in; return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index afe8477b..c0e0dad5 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,632 +1,632 @@ where('hidden', false) ->orderBy('months')->orderByDesc('title') ->get() ->map(function ($plan) { $button = self::trans("app.planbutton-{$plan->title}"); if (strpos($button, 'app.planbutton') !== false) { $button = self::trans('app.planbutton', ['plan' => $plan->name]); } return [ 'title' => $plan->title, 'name' => $plan->name, 'button' => $button, 'description' => $plan->description, 'mode' => $plan->mode ?: Plan::MODE_EMAIL, 'isDomain' => $plan->hasDomain(), ]; }) ->all(); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Returns list of public domains for signup. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function domains(Request $request) { return response()->json(['status' => 'success', 'domains' => Domain::getPublicDomains()]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { $rules = [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ]; $plan = $this->getPlan(); if ($plan->mode == Plan::MODE_TOKEN) { $rules['token'] = ['required', 'string', new SignupTokenRule($plan)]; } else { $rules['email'] = ['required', 'string', new SignupExternalEmail()]; } // Check required fields, validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()->toArray()], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $plan->mode == Plan::MODE_TOKEN ? $request->token : $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $plan->title, 'voucher' => $request->voucher, ]); $response = [ 'status' => 'success', 'code' => $code->code, 'mode' => $plan->mode ?: 'email', ]; if ($plan->mode == Plan::MODE_TOKEN) { // Token verification, jump to the last step $has_domain = $plan->hasDomain(); $response['short_code'] = $code->short_code; $response['is_domain'] = $has_domain; $response['domains'] = $has_domain ? [] : Domain::getPublicDomains(); } else { // External email verification, send an email message SignupVerificationEmail::dispatch($code); } return response()->json($response); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * @param bool $update Update the signup code record * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request, $update = true) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two $request->code = $code; if ($update) { $code->verify_ip_address = $request->ip(); $code->save(); } $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Validates the input to the final signup request. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signupValidate(Request $request) { $rules = [ 'login' => 'required|min:2', 'password' => ['required', 'confirmed', new Password()], 'domain' => 'required', 'voucher' => 'max:32', ]; // Direct signup by token if ($request->token) { // This will validate the token and the plan mode $plan = $request->plan ? Plan::withEnvTenantContext()->where('title', $request->plan)->first() : null; $rules['token'] = ['required', 'string', new SignupTokenRule($plan)]; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $settings = []; if (!empty($request->token)) { $settings = ['signup_token' => strtoupper($request->token)]; } elseif (!empty($request->plan) && empty($request->code) && empty($request->invitation)) { // Plan parameter is required/allowed in mandate mode $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); if (!$plan || $plan->mode != Plan::MODE_MANDATE) { $msg = self::trans('validation.exists', ['attribute' => 'plan']); return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422); } } elseif ($request->invitation) { // Signup via invitation $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // Validate verification codes (again) $v = $this->verify($request, false); if ($v->status() !== 200) { return $v; } $plan = $this->getPlan(); // Get user name/email from the verification code database $code_data = $v->getData(); $settings = [ 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; if ($plan->mode == Plan::MODE_TOKEN) { $settings['signup_token'] = strtoupper($code_data->email); } else { $settings['external_email'] = $code_data->email; } } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => self::trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } if (empty($plan)) { $plan = $this->getPlan(); } $is_domain = $plan->hasDomain(); // Validate login if ($errors = self::validateLogin($request->login, $request->domain, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Set some properties for signup() method $request->settings = $settings; $request->plan = $plan; $request->discount = $discount ?? null; $request->invitation = $invitation ?? null; $result = []; if ($plan->mode == Plan::MODE_MANDATE) { $result = $this->mandateForPlan($plan, $request->discount); } return response()->json($result + ['status' => 'success']); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { $v = $this->signupValidate($request); if ($v->status() !== 200) { return $v; } $is_domain = $request->plan->hasDomain(); // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($request->login); $domain_name = Str::lower($request->domain); $domain = null; $user_status = User::STATUS_RESTRICTED; if ( $request->discount && $request->discount->discount == 100 && $request->plan->mode == Plan::MODE_MANDATE ) { $user_status = User::STATUS_ACTIVE; } DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, 'status' => $user_status, ]); if ($request->discount) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($request->discount); $wallet->save(); } $user->assignPlan($request->plan, $domain); // Save the external email and plan in user settings $user->setSettings($request->settings); // Update the invitation if ($request->invitation) { $request->invitation->status = SignupInvitation::STATUS_COMPLETED; $request->invitation->user_id = $user->id; $request->invitation->save(); } // Soft-delete the verification code, and store some more info with it if ($request->code) { $request->code->user_id = $user->id; $request->code->submit_ip_address = $request->ip(); $request->code->deleted_at = \now(); $request->code->timestamps = false; $request->code->save(); } // Bump up counter on the signup token if (!empty($request->settings['signup_token'])) { \App\SignupToken::where('id', $request->settings['signup_token'])->increment('counter'); } DB::commit(); $response = AuthController::logonResponse($user, $request->password); if ($request->plan->mode == Plan::MODE_MANDATE) { $data = $response->getData(true); $data['checkout'] = $this->mandateForPlan($request->plan, $request->discount, $user); $response->setData($data); } return $response; } /** * Collects some content to display to the user before redirect to a checkout page. * Optionally creates a recurrent payment mandate for specified user/plan. */ protected function mandateForPlan(Plan $plan, Discount $discount = null, User $user = null): array { $result = []; $min = \App\Payment::MIN_AMOUNT; $planCost = $cost = $plan->cost(); $disc = 0; if ($discount) { // Free accounts don't need the auto-payment mandate // Note: This means the voucher code is the only point of user verification if ($discount->discount == 100) { return [ 'content' => self::trans('app.signup-account-free'), 'cost' => 0, ]; } $planCost = (int) ($planCost * (100 - $discount->discount) / 100); $disc = $cost - $planCost; } if ($planCost > $min) { $min = $planCost; } if ($user) { $wallet = $user->wallets()->first(); $wallet->setSettings([ 'mandate_amount' => sprintf('%.2F', round($min / 100, 2)), 'mandate_balance' => 0, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => \App\Tenant::getConfig($user->tenant_id, 'app.name') . ' ' . self::trans('app.mandate-description-suffix'), 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'redirectUrl' => Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); } $country = Utils::countryForRequest(); $period = $plan->months == 12 ? 'yearly' : 'monthly'; $currency = \config('app.currency'); $rate = VatRate::where('country', $country) ->where('start', '<=', now()->format('Y-m-d h:i:s')) ->orderByDesc('start') ->limit(1) ->first(); $summary = '' . '' . self::trans("app.signup-subscription-{$period}") . '' . '' . Utils::money($cost, $currency) . '' . ''; if ($discount) { $summary .= '' . '' . self::trans('app.discount-code', ['code' => $discount->code]) . '' . '' . Utils::money(-$disc, $currency) . '' . ''; } $summary .= '' . '' . '' . self::trans('app.total') . '' . '' . Utils::money($planCost, $currency) . '' . ''; if ($rate && $rate->rate > 0) { // TODO: app.vat.mode - $vat = round($planCost * $rate->rate / 100); + $vat = (int) round($planCost * $rate->rate / 100); $content = self::trans('app.vat-incl', [ 'rate' => Utils::percent($rate->rate), 'cost' => Utils::money($planCost - $vat, $currency), 'vat' => Utils::money($vat, $currency), ]); $summary .= '*' . $content . ''; } $trialEnd = $plan->free_months ? now()->copy()->addMonthsWithoutOverflow($plan->free_months) : now(); $params = [ 'cost' => Utils::money($planCost, $currency), 'date' => $trialEnd->toDateString(), ]; $result['title'] = self::trans("app.signup-plan-{$period}"); $result['content'] = self::trans('app.signup-account-mandate', $params); $result['summary'] = '' . $summary . '
'; $result['cost'] = $planCost; return $result; } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { $request = request(); if (!$request->plan || !$request->plan instanceof Plan) { // Get the plan if specified and exists... if (($request->code instanceof SignupCode) && $request->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); } elseif ($request->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } $request->plan = $plan; } return $request->plan; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::withTrashed()->where('namespace', $domain)->exists()) { return ['domain' => self::trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => self::trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index c8bbb95d..fb8c3c3d 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,609 +1,609 @@ guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' ' . self::trans('app.mandate-description-suffix'), 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < round($request->balance * 100)) { $mandate['amount'] = (int) round($request->amount * 100); self::addTax($wallet, $mandate); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => self::trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < round($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = self::trans('app.mandate-update-success'); return response()->json($result); } /** * Reset the auto-payment mandate, create a new payment for it. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateReset(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' ' . self::trans('app.mandate-description-suffix'), 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) round($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt, // and must be more than a yearly/monthly payment (according to the plan) $min = $wallet->getMinMandateAmount(); $label = 'minamount'; if ($wallet->balance < 0 && $wallet->balance < $min * -1) { $min = $wallet->balance * -1; $label = 'minamountdebt'; } if ($amount < $min) { return ['amount' => self::trans("validation.{$label}", ['amount' => $wallet->money($min)])]; } return null; } /** * Get status of the last payment. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentStatus() { $user = $this->guard()->user(); $wallet = $user->wallets()->first(); $payment = $wallet->payments()->orderBy('created_at', 'desc')->first(); if (empty($payment)) { return $this->errorResponse(404); } $done = [Payment::STATUS_PAID, Payment::STATUS_CANCELED, Payment::STATUS_FAILED, Payment::STATUS_EXPIRED]; if (in_array($payment->status, $done)) { $label = "app.payment-status-{$payment->status}"; } else { $label = "app.payment-status-checking"; } return response()->json([ 'id' => $payment->id, 'status' => $payment->status, 'type' => $payment->type, 'statusMessage' => self::trans($label), 'description' => $payment->description, ]); } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) round($request->amount * 100); // Validate the minimum value if ($amount < Payment::MIN_AMOUNT) { $min = $wallet->money(Payment::MIN_AMOUNT); $errors = ['amount' => self::trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $currency = $request->currency; $request = [ 'type' => Payment::TYPE_ONEOFF, 'currency' => $currency, 'amount' => $amount, 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; self::addTax($wallet, $request); $provider = PaymentProvider::factory($wallet, $currency); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); \Log::debug("Requested top-up for wallet {$wallet->id}"); if (!empty($settings['mandate_disabled'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate disabled"); return false; } $min_balance = (int) round(floatval($settings['mandate_balance']) * 100); $amount = (int) round(floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate invalid"); return false; } // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate - $wallet->setSetting('mandate_disabled', 1); + $wallet->setSetting('mandate_disabled', '1'); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $appName = Tenant::getConfig($wallet->owner->tenant_id, 'app.name'); $description = "{$appName} Recurring Payment"; if ($plan = $wallet->plan()) { if ($plan->months == 12) { $description = "{$appName} Annual Payment"; } elseif ($plan->months == 3) { $description = "{$appName} Quarterly Payment"; } elseif ($plan->months == 1) { $description = "{$appName} Monthly Payment"; } } $request = [ 'type' => Payment::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => $description, ]; self::addTax($wallet, $request); $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = $mandate['minAmount'] = round($wallet->getMinMandateAmount() / 100, 2); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; $mandate['isValid'] = !empty($mandate['isValid']); foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } // Unrestrict the wallet owner if mandate is valid if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) { $wallet->owner->unrestrict(); } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', Payment::TYPE_ONEOFF) ->whereIn('status', [ Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED ]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', Payment::TYPE_ONEOFF) ->whereIn('status', [ Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED ]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, 'currency' => $wallet->currency, // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Calculates tax for the payment, fills the request with additional properties * * @param \App\Wallet $wallet The wallet * @param array $request The request data with the payment amount */ protected static function addTax(Wallet $wallet, array &$request): void { $request['vat_rate_id'] = null; $request['credit_amount'] = $request['amount']; if ($rate = $wallet->vatRate()) { $request['vat_rate_id'] = $rate->id; switch (\config('app.vat.mode')) { case 1: // In this mode tax is added on top of the payment. The amount // to pay grows, but we keep wallet balance without tax. $request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100); break; default: // In this mode tax is "swallowed" by the vendor. The payment // amount does not change break; } } } } diff --git a/src/app/Http/Controllers/API/V4/RoomsController.php b/src/app/Http/Controllers/API/V4/RoomsController.php index 32e6e38f..267a61ee 100644 --- a/src/app/Http/Controllers/API/V4/RoomsController.php +++ b/src/app/Http/Controllers/API/V4/RoomsController.php @@ -1,312 +1,315 @@ inputRoom($id); if (is_int($room)) { return $this->errorResponse($room); } $room->delete(); return response()->json([ 'status' => 'success', 'message' => self::trans("app.room-delete-success"), ]); } /** * Listing of rooms that belong to the authenticated user. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $shared = Room::whereIn('id', function ($query) use ($user) { $query->select('permissible_id') ->from('permissions') ->where('permissible_type', Room::class) ->where('user', $user->email); }); // Create a "private" room for the user if (!$user->rooms()->count()) { $room = Room::create(); $room->assignToWallet($user->wallets()->first()); } - $rooms = $user->rooms(true)->union($shared)->orderBy('name')->get() + $rooms = $user->rooms(true) + ->union($shared) // @phpstan-ignore-line + ->orderBy('name') + ->get() ->map(function ($room) { return $this->objectToClient($room); }); $result = [ 'list' => $rooms, 'count' => count($rooms), ]; return response()->json($result); } /** * Set the room configuration. * * @param int|string $id Room identifier (or name) * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $room = $this->inputRoom($id, Permission::ADMIN, $permission); if (is_int($room)) { return $this->errorResponse($room); } $request = request()->input(); // Room sharees can't manage room ACL if ($permission) { unset($request['acl']); } $errors = $room->setConfig($request); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => self::trans("app.room-setconfig-success"), ]); } /** * Display information of a room specified by $id. * * @param string $id The room to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $room = $this->inputRoom($id, Permission::READ, $permission); if (is_int($room)) { return $this->errorResponse($room); } $wallet = $room->wallet(); $user = $this->guard()->user(); $response = $this->objectToClient($room, true); unset($response['session_id']); $response['config'] = $room->getConfig(); // Room sharees can't manage/see room ACL if ($permission) { unset($response['config']['acl']); } $response['skus'] = \App\Entitlement::objectEntitlementsSummary($room); $response['wallet'] = $wallet->toArray(); if ($wallet->discount) { $response['wallet']['discount'] = $wallet->discount->discount; $response['wallet']['discount_description'] = $wallet->discount->description; } $isOwner = $user->canDelete($room); $response['canUpdate'] = $isOwner || $room->permissions()->where('user', $user->email)->exists(); $response['canDelete'] = $isOwner && $user->wallet()->isController($user); $response['canShare'] = $isOwner && $room->hasSKU('group-room'); $response['isOwner'] = $isOwner; return response()->json($response); } /** * Get a list of SKUs available to the room. * * @param int $id Room identifier * * @return \Illuminate\Http\JsonResponse */ public function skus($id) { $room = $this->inputRoom($id); if (is_int($room)) { return $this->errorResponse($room); } return SkusController::objectSkus($room); } /** * Create a new room. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); $wallet = $user->wallet(); if (!$wallet->isController($user)) { return $this->errorResponse(403); } // Validate the input $v = Validator::make( $request->all(), [ 'description' => 'nullable|string|max:191' ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); $room = Room::create([ 'description' => $request->input('description'), ]); if (!empty($request->skus)) { SkusController::updateEntitlements($room, $request->skus, $wallet); } else { $room->assignToWallet($wallet); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => self::trans("app.room-create-success"), ]); } /** * Update a room. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Room identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $room = $this->inputRoom($id, Permission::ADMIN); if (is_int($room)) { return $this->errorResponse($room); } // Validate the input $v = Validator::make( request()->all(), [ 'description' => 'nullable|string|max:191' ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); $room->description = request()->input('description'); $room->save(); SkusController::updateEntitlements($room, $request->skus); if (!$room->hasSKU('group-room')) { $room->setSetting('acl', null); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => self::trans("app.room-update-success"), ]); } /** * Get the input room object, check permissions. * * @param int|string $id Room identifier (or name) * @param ?int $rights Required access rights * @param ?\App\Permission $permission Room permission reference if the user has permissions * to the room and is not the owner * * @return \App\Meet\Room|int File object or error code */ protected function inputRoom($id, $rights = 0, &$permission = null): int|Room { if (!is_numeric($id)) { $room = Room::where('name', $id)->first(); } else { $room = Room::find($id); } if (!$room) { return 404; } $user = $this->guard()->user(); // Room owner (or another wallet controller)? if ($room->wallet()->isController($user)) { return $room; } if ($rights) { $permission = $room->permissions()->where('user', $user->email)->first(); if ($permission && $permission->rights & $rights) { return $room; } } return 403; } } diff --git a/src/app/Providers/Payment/Coinbase.php b/src/app/Providers/Payment/Coinbase.php index aeee2519..bc3051d4 100644 --- a/src/app/Providers/Payment/Coinbase.php +++ b/src/app/Providers/Payment/Coinbase.php @@ -1,398 +1,400 @@ tag */ public function customerLink(Wallet $wallet): ?string { return null; } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function createMandate(Wallet $wallet, array $payment): ?array { throw new \Exception("not implemented"); } /** * Revoke the auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { throw new \Exception("not implemented"); } /** * Get a auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { throw new \Exception("not implemented"); } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'coinbase'; } /** * Creates HTTP client for connections to coinbase * * @return \GuzzleHttp\Client HTTP client instance */ private function client() { if (self::$testClient) { return self::$testClient; } if (!$this->client) { $this->client = new \GuzzleHttp\Client( [ 'http_errors' => false, // No exceptions from Guzzle 'base_uri' => 'https://api.commerce.coinbase.com/', 'verify' => \config('services.coinbase.api_verify_tls'), 'headers' => [ 'X-CC-Api-Key' => \config('services.coinbase.key'), 'X-CC-Version' => '2018-03-22', ], 'connect_timeout' => 10, 'timeout' => 10, 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { $threshold = \config('logging.slow_log'); if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { $url = $stats->getEffectiveUri(); $method = $stats->getRequest()->getMethod(); \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); } }, ] ); } return $this->client; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == Payment::TYPE_RECURRING) { throw new \Exception("not supported"); } $amount = $payment['amount'] / 100; $post = [ 'json' => [ "name" => \config('app.name'), "description" => $payment['description'], "pricing_type" => "fixed_price", 'local_price' => [ 'currency' => $wallet->currency, 'amount' => sprintf('%.2F', $amount), ], 'redirect_url' => self::redirectUrl() ] ]; $response = $this->client()->request('POST', '/charges/', $post); $code = $response->getStatusCode(); if ($code == 429) { $this->logError("Ratelimiting", $response); throw new \Exception("Failed to create coinbase charge due to rate-limiting: {$code}"); } if ($code !== 201) { $this->logError("Failed to create coinbase charge", $response); throw new \Exception("Failed to create coinbase charge: {$code}"); } $json = json_decode($response->getBody(), true); // Store the payment reference in database $payment['status'] = Payment::STATUS_OPEN; //We take the code instead of the id because it fits into our current db schema and the id doesn't $payment['id'] = $json['data']['code']; //We store in satoshis (the database stores it as INTEGER type) $payment['currency_amount'] = $json['data']['pricing']['bitcoin']['amount'] * self::SATOSHI_MULTIPLIER; $payment['currency'] = 'BTC'; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'newWindowUrl' => $json['data']['hosted_url'] ]; } /** * Log an error for a failed request to the meet server * * @param string $str The error string * @param object $response Guzzle client response */ private function logError(string $str, $response) { $code = $response->getStatusCode(); if ($code != 200 && $code != 201) { - \Log::error(var_export($response)); + \Log::error(var_export($response, true)); + $decoded = json_decode($response->getBody(), true); - $message = ""; + $message = ''; if ( is_array($decoded) && array_key_exists('error', $decoded) && is_array($decoded['error']) && array_key_exists('message', $decoded['error']) ) { $message = $decoded['error']['message']; } + \Log::error("$str [$code]: $message"); } } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = $this->client()->request('POST', "/charges/{$paymentId}/cancel"); if ($response->getStatusCode() == 200) { $db_payment = Payment::find($paymentId); $db_payment->status = Payment::STATUS_CANCELED; $db_payment->save(); } else { $this->logError("Failed to cancel payment", $response); return false; } return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { throw new \Exception("not available with coinbase"); } private static function verifySignature($payload, $sigHeader) { $secret = \config('services.coinbase.webhook_secret'); $computedSignature = \hash_hmac('sha256', $payload, $secret); if (!\hash_equals($sigHeader, $computedSignature)) { throw new \Exception("Coinbase request signature verification failed"); } } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { // We cannot just use php://input as it's already "emptied" by the framework $request = Request::instance(); $payload = $request->getContent(); $sigHeader = $request->header('X-CC-Webhook-Signature'); self::verifySignature($payload, $sigHeader); $data = \json_decode($payload, true); $event = $data['event']; $type = $event['type']; \Log::info("Coinbase webhook called " . $type); if ($type == 'charge:created') { return 200; } if ($type == 'charge:confirmed') { return 200; } if ($type == 'charge:pending') { return 200; } $payment_id = $event['data']['code']; if (empty($payment_id)) { \Log::warning(sprintf('Failed to find the payment for (%s)', $payment_id)); return 200; } $payment = Payment::find($payment_id); if (empty($payment)) { return 200; } $newStatus = Payment::STATUS_PENDING; // Even if we receive the payment delayed, we still have the money, and therefore credit it. if ($type == 'charge:resolved' || $type == 'charge:delayed') { // The payment is paid. Update the balance if ($payment->status != Payment::STATUS_PAID && $payment->amount > 0) { $credit = true; } $newStatus = Payment::STATUS_PAID; } elseif ($type == 'charge:failed') { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Coinbase payment failed (%s)', $payment->id)); $newStatus = Payment::STATUS_FAILED; } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $newStatus; $payment->save(); } if (!empty($credit)) { $payment->credit('Coinbase'); } DB::commit(); return 200; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * @param string $currency Currency code * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods(string $type, string $currency): array { $availableMethods = []; if ($type == Payment::TYPE_ONEOFF) { $availableMethods['bitcoin'] = [ 'id' => 'bitcoin', 'name' => "Bitcoin", 'minimumAmount' => 0.001, 'currency' => 'BTC' ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = Payment::find($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => true, 'checkoutUrl' => "https://commerce.coinbase.com/charges/{$paymentId}" ]; } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index 36213963..ee2b182c 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,630 +1,630 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::mollieCustomerId($wallet, false); if (!$customer_id) { return null; } return sprintf( '%s', $customer_id, $customer_id ); } /** * Validates that mollie available. * * @throws \Mollie\Api\Exceptions\ApiException on failure * @return bool true on success */ public static function healthcheck() { mollie()->methods()->allActive(); return true; } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * - redirectUrl: The location to goto after checkout * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); if (!isset($payment['amount'])) { $payment['amount'] = 0; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], 'value' => sprintf('%.2F', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'redirectUrl' => $payment['redirectUrl'] ?? self::redirectUrl(), 'locale' => 'en_US', 'method' => $payment['methodId'] ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); if ($response->mandateId) { $wallet->setSetting('mollie_mandate_id', $response->mandateId); } // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $payment['type'] = Payment::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); return [ 'id' => $response->id, 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Revoke the auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::mollieMandate($wallet); // Revoke the mandate on Mollie if ($mandate) { $mandate->revoke(); $wallet->setSetting('mollie_mandate_id', null); } return true; } /** * Get a auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::mollieMandate($wallet); if (empty($mandate)) { return null; } $result = [ 'id' => $mandate->id, 'isPending' => $mandate->isPending(), 'isValid' => $mandate->isValid(), 'method' => self::paymentMethod($mandate, 'Unknown method'), 'methodId' => $mandate->method ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'mollie'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == Payment::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required (note that JPK and ISK don't require decimals, // but we're not using them currently) 'value' => sprintf('%.2F', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'redirectUrl' => self::redirectUrl() // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: // billingEmail - for bank transfers, Przelewy24, but not creditcard // billingAddress (it is a structured field not just text) // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = mollie()->payments()->delete($paymentId); $db_payment = Payment::find($paymentId); $db_payment->status = $response->status; $db_payment->save(); return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::mollieMandate($wallet); if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { \Log::debug("Recurring payment for {$wallet->id}: no valid Mollie mandate"); return null; } $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2F', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; \Log::debug("Recurring payment for {$wallet->id}: " . json_encode($request)); // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; DB::beginTransaction(); $payment = $this->storePayment($payment, $wallet->id); // Mollie can return 'paid' status immediately, so we don't // have to wait for the webhook. What's more, the webhook would ignore // the payment because it will be marked as paid before the webhook. // Let's handle paid status here too. if ($response->isPaid()) { self::creditPayment($payment, $response); $notify = true; } elseif ($response->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $response->id)); // Disable the mandate - $wallet->setSetting('mandate_disabled', 1); + $wallet->setSetting('mandate_disabled', '1'); $notify = true; } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { $payment_id = \request()->input('id'); if (empty($payment_id)) { return 200; } $payment = Payment::find($payment_id); if (empty($payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } try { // Get the payment details from Mollie // TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed $mollie_payment = mollie()->payments()->get($payment_id); $refunds = []; if ($mollie_payment->isPaid()) { // The payment is paid. Update the balance, and notify the user if ($payment->status != Payment::STATUS_PAID && $payment->amount >= 0) { $credit = true; $notify = $payment->type == Payment::TYPE_RECURRING; } // The payment has been (partially) refunded. // Let's process refunds with status "refunded". if ($mollie_payment->hasRefunds()) { foreach ($mollie_payment->refunds() as $refund) { if ($refund->isTransferred() && $refund->amount->value) { $refunds[] = [ 'id' => $refund->id, 'description' => $refund->description, 'amount' => round(floatval($refund->amount->value) * 100), 'type' => Payment::TYPE_REFUND, 'currency' => $refund->amount->currency ]; } } } // The payment has been (partially) charged back. // Let's process chargebacks (they have no states as refunds) if ($mollie_payment->hasChargebacks()) { foreach ($mollie_payment->chargebacks() as $chargeback) { if ($chargeback->amount->value) { $refunds[] = [ 'id' => $chargeback->id, 'amount' => round(floatval($chargeback->amount->value) * 100), 'type' => Payment::TYPE_CHARGEBACK, 'currency' => $chargeback->amount->currency ]; } } } // In case there were multiple auto-payment setup requests (e.g. caused by a double // form submission) we end up with multiple payment records and mollie_mandate_id // pointing to the one from the last payment not the successful one. // We make sure to use mandate id from the successful "first" payment. if ( $payment->type == Payment::TYPE_MANDATE && $mollie_payment->mandateId && $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST ) { $payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId); } } elseif ($mollie_payment->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); // Disable the mandate if ($payment->type == Payment::TYPE_RECURRING) { $notify = true; - $payment->wallet->setSetting('mandate_disabled', 1); + $payment->wallet->setSetting('mandate_disabled', '1'); } } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $mollie_payment->status; $payment->save(); } if (!empty($credit)) { self::creditPayment($payment, $mollie_payment); } foreach ($refunds as $refund) { $payment->refund($refund); } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } } catch (\Mollie\Api\Exceptions\ApiException $e) { \Log::warning(sprintf('Mollie api call failed (%s)', $e->getMessage())); } return 200; } /** * Get Mollie customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return ?string Mollie customer identifier */ protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie if (empty($customer_id) && $create) { $customer = mollie()->customers()->create([ 'name' => $wallet->owner->name(), 'email' => $wallet->id . '@private.' . \config('app.domain'), ]); $customer_id = $customer->id; $wallet->setSetting('mollie_id', $customer->id); } return $customer_id; } /** * Get the active Mollie auto-payment mandate */ protected static function mollieMandate(Wallet $wallet) { $settings = $wallet->getSettings(['mollie_id', 'mollie_mandate_id']); // Get the manadate reference we already have if ($settings['mollie_id'] && $settings['mollie_mandate_id']) { try { return mollie()->mandates()->getForId($settings['mollie_id'], $settings['mollie_mandate_id']); } catch (ApiException $e) { // FIXME: What about 404? if ($e->getCode() == 410) { // The mandate is gone, remove the reference $wallet->setSetting('mollie_mandate_id', null); return null; } // TODO: Maybe we shouldn't always throw? It make sense in the job // but for example when we're just fetching wallet info... throw $e; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment($payment, $mollie_payment) { // Extract the payment method for transaction description $method = self::paymentMethod($mollie_payment, 'Mollie'); $payment->credit($method); } /** * Extract payment method description from Mollie payment/mandate details */ protected static function paymentMethod($object, $default = ''): string { $details = $object->details; // Mollie supports 3 methods here switch ($object->method) { case self::METHOD_CREDITCARD: // If the customer started, but never finished the 'first' payment // card details will be empty, and mandate will be 'pending'. if (empty($details->cardNumber)) { return 'Credit Card'; } return sprintf( '%s (**** **** **** %s)', $details->cardLabel ?: 'Card', // @phpstan-ignore-line $details->cardNumber ); case self::METHOD_DIRECTDEBIT: return sprintf('Direct Debit (%s)', $details->customerAccount); case self::METHOD_PAYPAL: return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * @param string $currency Currency code * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods(string $type, string $currency): array { // Prefer methods in the system currency $providerMethods = (array) mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => $currency ] ] ); // Get EUR methods (e.g. bank transfers are in EUR only) if ($currency != 'EUR') { $eurMethods = (array) mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => 'EUR' ] ] ); // Later provider methods will override earlier ones $providerMethods = array_merge($eurMethods, $providerMethods); } $availableMethods = []; foreach ($providerMethods as $method) { $availableMethods[$method->id] = [ 'id' => $method->id, 'name' => $method->description, 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents 'currency' => $method->minimumAmount->currency, 'exchangeRate' => \App\Utils::exchangeRate($currency, $method->minimumAmount->currency) ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = mollie()->payments()->get($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => $payment->isCancelable, 'checkoutUrl' => $payment->getCheckoutUrl() ]; } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php index 43f66120..4b74e21a 100644 --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -1,555 +1,555 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::stripeCustomerId($wallet, false); if (!$customer_id) { return null; } $location = 'https://dashboard.stripe.com'; $key = \config('services.stripe.key'); if (strpos($key, 'sk_test_') === 0) { $location .= '/test'; } return sprintf( '%s', $location, $customer_id, $customer_id ); } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (not used) * - currency: The operation currency * - description: Operation desc. * - redirectUrl: The location to goto after checkout * * @return array Provider payment/session data: * - id: Session identifier */ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Stripe, if not yet done $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, 'cancel_url' => $payment['redirectUrl'] ?? self::redirectUrl(), // required 'success_url' => $payment['redirectUrl'] ?? self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', ]; // Note: Stripe does not allow to set amount for 'setup' operation // We'll dispatch WalletCharge job when we receive a webhook request $session = StripeAPI\Checkout\Session::create($request); $payment['amount'] = 0; $payment['credit_amount'] = 0; $payment['currency_amount'] = 0; $payment['vat_rate_id'] = null; $payment['id'] = $session->setup_intent; $payment['type'] = Payment::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, ]; } /** * Revoke the auto-payment mandate. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::stripeMandate($wallet); if ($mandate) { // Remove the reference $wallet->setSetting('stripe_mandate_id', null); // Detach the payment method on Stripe $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); $pm->detach(); } return true; } /** * Get a auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::stripeMandate($wallet); if (empty($mandate)) { return null; } $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); $result = [ 'id' => $mandate->id, 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', 'isValid' => $mandate->status == 'succeeded', 'method' => self::paymentMethod($pm, 'Unknown method') ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'stripe'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation desc. * * @return array Provider payment/session data: * - id: Session identifier */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == Payment::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Stripe, if not yet done $customer_id = self::stripeCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'customer' => $customer_id, 'cancel_url' => self::redirectUrl(), // required 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ [ 'name' => $payment['description'], 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'quantity' => 1, ] ] ]; $session = StripeAPI\Checkout\Session::create($request); // Store the payment reference in database $payment['id'] = $session->payment_intent; $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, ]; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Session identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::stripeMandate($wallet); if (empty($mandate)) { return null; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'description' => $payment['description'], 'receipt_email' => $wallet->owner->email, 'customer' => $mandate->customer, 'payment_method' => $mandate->payment_method, 'off_session' => true, 'confirm' => true, ]; $intent = StripeAPI\PaymentIntent::create($request); // Store the payment reference in database $payment['id'] = $intent->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { // We cannot just use php://input as it's already "emptied" by the framework // $payload = file_get_contents('php://input'); $request = Request::instance(); $payload = $request->getContent(); $sig_header = $request->header('Stripe-Signature'); // Parse and validate the input try { $event = StripeAPI\Webhook::constructEvent( $payload, $sig_header, \config('services.stripe.webhook_secret') ); } catch (\Exception $e) { \Log::error("Invalid payload: " . $e->getMessage()); // Invalid payload return 400; } switch ($event->type) { case StripeAPI\Event::PAYMENT_INTENT_CANCELED: case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type == Payment::TYPE_MANDATE) { return 404; } switch ($intent->status) { case StripeAPI\PaymentIntent::STATUS_CANCELED: $status = Payment::STATUS_CANCELED; break; case StripeAPI\PaymentIntent::STATUS_SUCCEEDED: $status = Payment::STATUS_PAID; break; default: $status = Payment::STATUS_FAILED; } DB::beginTransaction(); if ($status == Payment::STATUS_PAID) { // Update the balance, if it wasn't already if ($payment->status != Payment::STATUS_PAID) { $this->creditPayment($payment, $intent); } } else { if (!empty($intent->last_payment_error)) { // See https://stripe.com/docs/error-codes for more info \Log::info(sprintf( 'Stripe payment failed (%s): %s', $payment->id, json_encode($intent->last_payment_error) )); } } if ($payment->status != Payment::STATUS_PAID) { $payment->status = $status; $payment->save(); if ($status != Payment::STATUS_CANCELED && $payment->type == Payment::TYPE_RECURRING) { // Disable the mandate if ($status == Payment::STATUS_FAILED) { - $payment->wallet->setSetting('mandate_disabled', 1); + $payment->wallet->setSetting('mandate_disabled', '1'); } // Notify the user \App\Jobs\PaymentEmail::dispatch($payment); } } DB::commit(); break; case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED: case StripeAPI\Event::SETUP_INTENT_CANCELED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type != Payment::TYPE_MANDATE) { return 404; } switch ($intent->status) { case StripeAPI\SetupIntent::STATUS_CANCELED: $status = Payment::STATUS_CANCELED; break; case StripeAPI\SetupIntent::STATUS_SUCCEEDED: $status = Payment::STATUS_PAID; break; default: $status = Payment::STATUS_FAILED; } if ($status == Payment::STATUS_PAID) { $payment->wallet->setSetting('stripe_mandate_id', $intent->id); $threshold = (int) round((float) $payment->wallet->getSetting('mandate_balance') * 100); // Call credit() so wallet/account state is updated $this->creditPayment($payment, $intent); // Top-up the wallet if balance is below the threshold if ($payment->wallet->balance < $threshold && $payment->status != Payment::STATUS_PAID) { \App\Jobs\WalletCharge::dispatch($payment->wallet); } } $payment->status = $status; $payment->save(); break; default: \Log::debug("Unhandled Stripe event: " . var_export($payload, true)); break; } return 200; } /** * Get Stripe customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return string|null Stripe customer identifier */ protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('stripe_id'); // Register the user in Stripe if (empty($customer_id) && $create) { $customer = StripeAPI\Customer::create([ 'name' => $wallet->owner->name(), // Stripe will display the email on Checkout page, editable, // and use it to send the receipt (?), use the user email here // 'email' => $wallet->id . '@private.' . \config('app.domain'), 'email' => $wallet->owner->email, ]); $customer_id = $customer->id; $wallet->setSetting('stripe_id', $customer->id); } return $customer_id; } /** * Get the active Stripe auto-payment mandate (Setup Intent) */ protected static function stripeMandate(Wallet $wallet) { // Note: Stripe also has 'Mandate' objects, but we do not use these if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); // @phpstan-ignore-next-line if ($mandate && $mandate->status != 'canceled') { return $mandate; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment(Payment $payment, $intent) { $method = 'Stripe'; // Extract the payment method for transaction description if ( !empty($intent->charges) && ($charge = $intent->charges->data[0]) && ($pm = $charge->payment_method_details) ) { $method = self::paymentMethod($pm); } $payment->credit($method); } /** * Extract payment method description from Stripe payment details */ protected static function paymentMethod($details, $default = ''): string { switch ($details->type) { case 'card': // TODO: card number return \sprintf( '%s (**** **** **** %s)', \ucfirst($details->card->brand) ?: 'Card', $details->card->last4 ); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * @param string $currency Currency code * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods(string $type, string $currency): array { //TODO get this from the stripe API? $availableMethods = []; switch ($type) { case Payment::TYPE_ONEOFF: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => Payment::MIN_AMOUNT, 'currency' => $currency, 'exchangeRate' => 1.0 ], self::METHOD_PAYPAL => [ 'id' => self::METHOD_PAYPAL, 'name' => "PayPal", 'minimumAmount' => Payment::MIN_AMOUNT, 'currency' => $currency, 'exchangeRate' => 1.0 ] ]; break; case Payment::TYPE_RECURRING: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => Payment::MIN_AMOUNT, // Converted to cents, 'currency' => $currency, 'exchangeRate' => 1.0 ] ]; break; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl."); $payment = StripeAPI\PaymentIntent::retrieve($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => false, 'checkoutUrl' => null ]; } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php index 47eec4b2..13561aa7 100644 --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -1,92 +1,92 @@ The attributes that are mass assignable */ protected $fillable = ['id', 'title']; /** * Utility method to get tenant-specific system setting. * If the setting is not specified for the tenant a system-wide value will be returned. * - * @param int $tenantId Tenant identifier + * @param ?int $tenantId Tenant identifier * @param string $key Setting name * * @return mixed Setting value */ public static function getConfig($tenantId, string $key) { // Cache the tenant instance in memory static $tenant; if (empty($tenant) || $tenant->id != $tenantId) { $tenant = null; if ($tenantId) { $tenant = self::findOrFail($tenantId); } } // Supported options (TODO: document this somewhere): // - app.name (tenants.title will be returned) // - app.public_url and app.url // - app.support_url // - mail.from.address and mail.from.name // - mail.reply_to.address and mail.reply_to.name // - app.kb.account_delete and app.kb.account_suspended // - pgp.enable if ($key == 'app.name') { return $tenant ? $tenant->title : \config($key); } $value = $tenant ? $tenant->getSetting($key) : null; return $value !== null ? $value : \config($key); } /** * Discounts assigned to this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function discounts() { return $this->hasMany(Discount::class); } /** * SignupInvitations assigned to this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function signupInvitations() { return $this->hasMany(SignupInvitation::class); } /* * Returns the wallet of the tanant (reseller's wallet). * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $user = User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); return $user ? $user->wallets->first() : null; } } diff --git a/src/app/Traits/UserConfigTrait.php b/src/app/Traits/UserConfigTrait.php index 998f26c7..24e18544 100644 --- a/src/app/Traits/UserConfigTrait.php +++ b/src/app/Traits/UserConfigTrait.php @@ -1,143 +1,143 @@ getSettings([ 'greylist_enabled', 'guam_enabled', 'password_policy', 'max_password_age', 'limit_geo' ]); $config = [ 'greylist_enabled' => $settings['greylist_enabled'] !== 'false', 'guam_enabled' => $settings['guam_enabled'] === 'true', 'limit_geo' => $settings['limit_geo'] ? json_decode($settings['limit_geo'], true) : [], 'max_password_age' => $settings['max_password_age'], 'password_policy' => $settings['password_policy'], ]; return $config; } /** * A helper to update user configuration. * * @param array $config An array of configuration options * * @return array A list of input validation error messages */ public function setConfig(array $config): array { $errors = []; foreach ($config as $key => $value) { if ($key == 'greylist_enabled') { $this->setSetting($key, $value ? 'true' : 'false'); } elseif ($key == 'guam_enabled') { $this->setSetting($key, $value ? 'true' : null); } elseif ($key == 'limit_geo') { if (!is_array($value)) { $errors[$key] = \trans('validation.invalid-limit-geo'); continue; } foreach ($value as $idx => $country) { if (!preg_match('/^[a-zA-Z]{2}$/', $country)) { $errors[$key] = \trans('validation.invalid-limit-geo'); continue 2; } $value[$idx] = \strtoupper($country); } if (count($value) > 250) { $errors[$key] = \trans('validation.invalid-limit-geo'); } $this->setSetting($key, !empty($value) ? json_encode($value) : null); } elseif ($key == 'max_password_age') { - $this->setSetting($key, intval($value) > 0 ? (int) $value : null); + $this->setSetting($key, intval($value) > 0 ? ((string) intval($value)) : null); } elseif ($key == 'password_policy') { // Validate the syntax and make sure min and max is included if ( !is_string($value) || strpos($value, 'min:') === false || strpos($value, 'max:') === false || !preg_match('/^[a-z0-9:,]+$/', $value) ) { $errors[$key] = \trans('validation.invalid-password-policy'); continue; } foreach (explode(',', $value) as $rule) { if ($error = $this->validatePasswordPolicyRule($rule)) { $errors[$key] = $error; continue 2; } } $this->setSetting($key, $value); } else { $errors[$key] = \trans('validation.invalid-config-parameter'); } } return $errors; } /** * Validates password policy rule. * * @param string $rule Policy rule * * @return ?string An error message on error, Null otherwise */ protected function validatePasswordPolicyRule(string $rule): ?string { $regexp = [ 'min:[0-9]+', 'max:[0-9]+', 'upper', 'lower', 'digit', 'special', 'last:[0-9]+' ]; if (empty($rule) || !preg_match('/^(' . implode('|', $regexp) . ')$/', $rule)) { return \trans('validation.invalid-password-policy'); } $systemPolicy = \App\Rules\Password::parsePolicy(\config('app.password_policy')); // Min/Max values cannot exceed the system defaults, i.e. if system policy // is min:5, user's policy cannot be set to a smaller number. if (!empty($systemPolicy['min']) && strpos($rule, 'min:') === 0) { $value = trim(substr($rule, 4)); if ($value < $systemPolicy['min']) { return \trans('validation.password-policy-min-len-error', ['min' => $systemPolicy['min']]); } } if (!empty($systemPolicy['max']) && strpos($rule, 'max:') === 0) { $value = trim(substr($rule, 4)); if ($value > $systemPolicy['max']) { return \trans('validation.password-policy-max-len-error', ['max' => $systemPolicy['max']]); } } if (!empty($systemPolicy['last']) && strpos($rule, 'last:') === 0) { $value = trim(substr($rule, 5)); if ($value < $systemPolicy['last']) { return \trans('validation.password-policy-last-error', ['last' => $systemPolicy['last']]); } } return null; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 632dc0d0..6b5bd285 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,605 +1,605 @@ country ? $net->country : $fallback; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * 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); } /** * Converts an email address to lower case. Keeps the LMTP shared folder * addresses character case intact. * * @param string $email Email address * * @return string Email address */ public static function emailToLower(string $email): string { // For LMTP shared folder address lower case the domain part only if (str_starts_with($email, 'shared+shared/')) { $pos = strrpos($email, '@'); $domain = substr($email, $pos + 1); $local = substr($email, 0, strlen($email) - strlen($domain) - 1); return $local . '@' . strtolower($domain); } return strtolower($email); } /** * Make sure that IMAP folder access rights contains "anyone: p" permission * * @param array $acl ACL (in form of "user, permission" records) * * @return array ACL list */ public static function ensureAclPostPermission(array $acl): array { foreach ($acl as $idx => $entry) { if (str_starts_with($entry, 'anyone,')) { if (strpos($entry, 'read-only')) { $acl[$idx] = 'anyone, lrsp'; } elseif (strpos($entry, 'read-write')) { $acl[$idx] = 'anyone, lrswitednp'; } return $acl; } } $acl[] = 'anyone, p'; return $acl; } /** * 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 = self::emailToLower($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}"; } /** * 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] = []; + $string = []; for ($y = 0; $y < $length; $y++) { - $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; + $string[] = $chars[rand(0, strlen($chars) - 1)]; } - shuffle($randStrs[$x]); + shuffle($string); - $randStrs[$x] = implode('', $randStrs[$x]); + $randStrs[$x] = implode('', $string); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return int */ public static function uuidInt(): int { $hex = self::uuidStr(); $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 (string) Str::uuid(); } /** * Create self URL * * @param string $route Route/Path/URL * @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 { if (preg_match('|^https?://|i', $route)) { return $route; } $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', 'app.company.copyright', 'app.companion_download_link', 'app.with_signup', '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; } /** * Set test exchange rates. * * @param array $rates: Exchange rates */ public static function setTestExchangeRates(array $rates): void { self::$testRates = $rates; } /** * 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; } if (isset(self::$testRates[$targetCurrency])) { return floatval(self::$testRates[$targetCurrency]); } $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]); } /** * A helper to display human-readable amount of money using * for specified currency and locale. * * @param int $amount Amount of money (in cents) * @param string $currency Currency code * @param string $locale Output locale * * @return string String representation, e.g. "9.99 CHF" */ public static function money(int $amount, $currency, $locale = 'de_DE'): string { $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency(round($amount / 100, 2), $currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * A helper to display human-readable percent value * for specified currency and locale. * * @param int|float $percent Percent value (0 to 100) * @param string $locale Output locale * * @return string String representation, e.g. "0 %", "7.7 %" */ public static function percent(int|float $percent, $locale = 'de_DE'): string { $nf = new \NumberFormatter($locale, \NumberFormatter::PERCENT); $sep = $nf->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); $result = sprintf('%.2F', $percent); $result = preg_replace('/\.00/', '', $result); $result = preg_replace('/(\.[0-9])0/', '\\1', $result); $result = str_replace('.', $sep, $result); return $result . ' %'; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 9da4684d..c684e4de 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,722 +1,724 @@ 0, ]; /** @var array The attributes that are mass assignable */ protected $fillable = [ 'currency', 'description' ]; /** @var array The attributes that can be not set */ protected $nullable = [ 'description', ]; /** @var array The types of attributes to which its values will be cast */ protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } /** * Add an award to this wallet's balance. * * @param int|\App\Payment $amount The amount of award (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function award(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_AWARD, $amount, $description); } /** * Charge entitlements in the wallet * * @param bool $apply Set to false for a dry-run mode * * @return int Charged amount in cents */ public function chargeEntitlements($apply = true): int { $transactions = []; $profit = 0; $charges = 0; $isDegraded = $this->owner->isDegraded(); $trial = $this->trialInfo(); if ($apply) { DB::beginTransaction(); } // Get all relevant entitlements... $entitlements = $this->entitlements()->withTrashed() // existing entitlements created, or billed last less than a month ago + // @phpstan-ignore-next-line ->where(function (Builder $query) { $query->whereNull('deleted_at') ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)); }) // deleted entitlements not yet charged + // @phpstan-ignore-next-line ->orWhere(function (Builder $query) { $query->whereNotNull('deleted_at') ->whereColumn('updated_at', '<', 'deleted_at'); }) ->get(); foreach ($entitlements as $entitlement) { // Calculate cost, fee, and end of period [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial); // Note: Degraded pays nothing, but we get the money from a tenant. // Therefore $cost = 0, but $profit < 0. if ($isDegraded) { $cost = 0; } $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } if ($endDate) { $entitlement->updated_at = $endDate; $entitlement->save(); } if ($cost == 0) { continue; } $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } if ($apply) { $this->debit($charges, '', $transactions)->addTenantProfit($profit); DB::commit(); } return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } $balance = $this->balance; $discount = $this->getDiscountRate(); $trial = $this->trialInfo(); // Get all entitlements... $entitlements = $this->entitlements()->orderBy('updated_at')->get() ->filter(function ($entitlement) { return $entitlement->cost > 0; }) ->map(function ($entitlement) { return [ 'date' => $entitlement->updated_at ?: $entitlement->created_at, 'cost' => $entitlement->cost, 'sku_id' => $entitlement->sku_id, ]; }) ->all(); $max = 12 * 25; while ($max > 0) { foreach ($entitlements as &$entitlement) { $until = $entitlement['date'] = $entitlement['date']->addMonthsWithoutOverflow(1); if ( !empty($trial) && $entitlement['date'] < $trial['end'] && in_array($entitlement['sku_id'], $trial['skus']) ) { continue; } $balance -= (int) ($entitlement['cost'] * $discount); if ($balance < 0) { break 2; } } $max--; } if (empty($until)) { return null; } // Don't return dates from the past if ($until <= Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Chargeback an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to charge back (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function chargeback(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description); } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( User::class, // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Add an amount of pecunia to this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to add (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function credit(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_CREDIT, $amount, $description); } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to deduct (in cents) or Payment object * @param string $description The transaction description * @param array $eTIDs List of transaction IDs for the individual entitlements * that make up this debit record, if any. * @return Wallet Self */ public function debit(int|Payment $amount, string $description = '', array $eTIDs = []): Wallet { return $this->balanceUpdate(Transaction::WALLET_DEBIT, $amount, $description, $eTIDs); } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo(Discount::class, 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany(Entitlement::class); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * @return int Discount in percent, ranges from 0 - 100. */ public function getDiscount(): int { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * @return float Discount rate, ranges from 0.00 to 1.00. */ public function getDiscountRate(): float { return (100 - $this->getDiscount()) / 100; } /** * The minimum amount of an auto-payment mandate * * @return int Amount in cents */ public function getMinMandateAmount(): int { $min = Payment::MIN_AMOUNT; if ($plan = $this->plan()) { $planCost = (int) ($plan->cost() * $this->getDiscountRate()); if ($planCost > $min) { $min = $planCost; } } return $min; } /** * Check if the specified user is a controller to this wallet. * * @param \App\User $user The user object. * * @return bool True if the user is one of the wallet controllers (including user), False otherwise */ public function isController(User $user): bool { return $user->id == $this->user_id || $this->controllers->contains($user); } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { return \App\Utils::money($amount, $this->currency, $locale); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo(User::class, 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany(Payment::class); } /** * Add a penalty to this wallet's balance. * * @param int|\App\Payment $amount The amount of penalty (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function penalty(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description); } /** * Plan of the wallet. * * @return ?\App\Plan */ public function plan() { $planId = $this->owner->getSetting('plan_id'); return $planId ? Plan::find($planId) : null; } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Refund an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function refund($amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description); } /** * Get the VAT rate for the wallet owner country. * * @param ?\DateTime $start Get the rate valid for the specified date-time, * without it the current rate will be returned (if exists). * * @return ?\App\VatRate VAT rate */ public function vatRate(\DateTime $start = null): ?VatRate { $owner = $this->owner; // Make it working with deleted accounts too if (!$owner) { $owner = $this->owner()->withTrashed()->first(); } $country = $owner->getSetting('country'); if (!$country) { return null; } return VatRate::where('country', $country) ->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s')) ->orderByDesc('start') ->limit(1) ->first(); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class); } /** * Returns trial related information. * * @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months) */ public function trialInfo(): ?array { $plan = $this->plan(); $freeMonths = $plan ? $plan->free_months : 0; $trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; if ($trialEnd) { // Get all SKUs assigned to the plan (they are free in trial) // TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons: // - performance // - if we change plan definition at some point in time, the old users would use // the old definition, instead of the current one // TODO: The same for plan's free_months value $trialSkus = \App\Sku::select('id') ->whereIn('id', function ($query) use ($plan) { $query->select('sku_id') ->from('package_skus') ->whereIn('package_id', function ($query) use ($plan) { $query->select('package_id') ->from('plan_packages') ->where('plan_id', $plan->id); }); }) ->whereNot('title', 'storage') ->pluck('id') ->all(); return [ 'end' => $trialEnd, 'skus' => $trialSkus, 'planId' => $plan->id, 'months' => $freeMonths, ]; } return null; } /** * Force-update entitlements' updated_at, charge if needed. * * @param bool $withCost When enabled the cost will be charged * * @return int Charged amount in cents */ public function updateEntitlements($withCost = true): int { $charges = 0; $profit = 0; $trial = $this->trialInfo(); DB::beginTransaction(); $transactions = []; $entitlements = $this->entitlements()->where('updated_at', '<', Carbon::now())->get(); foreach ($entitlements as $entitlement) { // Calculate cost, fee, and end of period [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial, true); // Note: Degraded pays nothing, but we get the money from a tenant. // Therefore $cost = 0, but $profit < 0. if (!$withCost) { $cost = 0; } if ($endDate) { $entitlement->updated_at = $entitlement->updated_at->setDateFrom($endDate); $entitlement->save(); } $charges += $cost; $profit += $cost - $fee; if ($cost == 0) { continue; } // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } $this->debit($charges, '', $transactions)->addTenantProfit($profit); DB::commit(); return $charges; } /** * Add profit to the tenant's wallet * * @param int $profit Profit amount (in cents), can be negative */ protected function addTenantProfit($profit): void { // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; if ($profit > 0) { $wallet->credit(abs($profit), $desc); } else { $wallet->debit(abs($profit), $desc); } } } } /** * Update the wallet balance, and create a transaction record */ protected function balanceUpdate(string $type, int|Payment $amount, $description = null, array $eTIDs = []) { if ($amount instanceof Payment) { $amount = $amount->credit_amount; } if ($amount === 0) { return $this; } if (in_array($type, [Transaction::WALLET_CREDIT, Transaction::WALLET_AWARD])) { $amount = abs($amount); } else { $amount = abs($amount) * -1; } $this->balance += $amount; $this->save(); $transaction = Transaction::create([ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => $type, 'amount' => $amount, 'description' => $description, ]); if (!empty($eTIDs)) { Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * Calculate entitlement cost/fee for the current charge * * @param Entitlement $entitlement Entitlement object * @param array|null $trial Trial information (result of Wallet::trialInfo()) * @param bool $useCostPerDay Force calculation based on a per-day cost * * @return array Result in form of [cost, fee, end-of-period] */ protected function entitlementCosts(Entitlement $entitlement, array $trial = null, bool $useCostPerDay = false) { $discountRate = $this->getDiscountRate(); $startDate = $entitlement->updated_at; // start of the period to charge for $endDate = Carbon::now(); // end of the period to charge for // Deleted entitlements are always charged for all uncharged days up to the delete date if ($entitlement->trashed()) { $useCostPerDay = true; $endDate = $entitlement->deleted_at->copy(); } // Consider Trial period if (!empty($trial) && $startDate < $trial['end'] && in_array($entitlement->sku_id, $trial['skus'])) { if ($trial['end'] > $endDate) { return [0, 0, $trial['end']]; } $startDate = $trial['end']; } if ($useCostPerDay) { // Note: In this mode we need a full cost including partial periods. // Anything's free for the first 14 days. if ($entitlement->created_at >= $endDate->copy()->subDays(14)) { return [0, 0, $endDate]; } $cost = 0; $fee = 0; // Charge for full months first if (($diff = $startDate->diffInMonths($endDate)) > 0) { $cost += floor($entitlement->cost * $discountRate) * $diff; $fee += $entitlement->fee * $diff; $startDate->addMonthsWithoutOverflow($diff); } // Charge for the rest of the period if (($diff = $startDate->diffInDays($endDate)) > 0) { // The price per day is based on the number of days in the month(s) // Note: The $endDate does not have to be the current month $endMonthDiff = $endDate->day > $diff ? $diff : $endDate->day; $startMonthDiff = $diff - $endMonthDiff; // FIXME: This could be calculated in a few different ways, e.g. rounding or flooring // the daily cost first and then applying discount and number of days. This could lead // to very small values in some cases resulting in a zero result. $cost += floor($entitlement->cost / $endDate->daysInMonth * $discountRate * $endMonthDiff); $fee += floor($entitlement->fee / $endDate->daysInMonth * $endMonthDiff); if ($startMonthDiff) { $cost += floor($entitlement->cost / $startDate->daysInMonth * $discountRate * $startMonthDiff); $fee += floor($entitlement->fee / $startDate->daysInMonth * $startMonthDiff); } } } else { // Note: In this mode we expect to charge the entitlement for full month(s) only $diff = $startDate->diffInMonths($endDate); if ($diff <= 0) { // Do not update updated_at column (not a full month) unless trial end date // is after current updated_at date return [0, 0, $startDate != $entitlement->updated_at ? $startDate : null]; } $endDate = $startDate->addMonthsWithoutOverflow($diff); $cost = floor($entitlement->cost * $discountRate) * $diff; $fee = $entitlement->fee * $diff; } return [(int) $cost, (int) $fee, $endDate]; } } diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php index adb0b7b8..0f2aecf8 100644 --- a/src/include/rcube_imap_generic.php +++ b/src/include/rcube_imap_generic.php @@ -1,4128 +1,4128 @@ | | Author: Ryo Chijiiwa | +-----------------------------------------------------------------------+ */ /** * PHP based wrapper class to connect to an IMAP server * * @package Framework * @subpackage Storage */ class rcube_imap_generic { public $error; public $errornum; public $result; public $resultcode; public $selected; public $data = array(); public $flags = array( 'SEEN' => '\\Seen', 'DELETED' => '\\Deleted', 'ANSWERED' => '\\Answered', 'DRAFT' => '\\Draft', 'FLAGGED' => '\\Flagged', 'FORWARDED' => '$Forwarded', 'MDNSENT' => '$MDNSent', '*' => '\\*', ); protected $fp; protected $host; protected $cmd_tag; protected $cmd_num = 0; protected $resourceid; protected $prefs = array(); protected $logged = false; protected $capability = array(); protected $capability_readed = false; protected $debug = false; protected $debug_handler = false; const ERROR_OK = 0; const ERROR_NO = -1; const ERROR_BAD = -2; const ERROR_BYE = -3; const ERROR_UNKNOWN = -4; const ERROR_COMMAND = -5; const ERROR_READONLY = -6; const COMMAND_NORESPONSE = 1; const COMMAND_CAPABILITY = 2; const COMMAND_LASTLINE = 4; const COMMAND_ANONYMIZED = 8; const DEBUG_LINE_LENGTH = 4098; // 4KB + 2B for \r\n /** * Send simple (one line) command to the connection stream * * @param string $string Command string * @param bool $endln True if CRLF need to be added at the end of command * @param bool $anonymized Don't write the given data to log but a placeholder * * @param int Number of bytes sent, False on error */ protected function putLine($string, $endln = true, $anonymized = false) { if (!$this->fp) { return false; } if ($this->debug) { // anonymize the sent command for logging $cut = $endln ? 2 : 0; if ($anonymized && preg_match('/^(A\d+ (?:[A-Z]+ )+)(.+)/', $string, $m)) { $log = $m[1] . sprintf('****** [%d]', strlen($m[2]) - $cut); } else if ($anonymized) { $log = sprintf('****** [%d]', strlen($string) - $cut); } else { $log = rtrim($string); } $this->debug('C: ' . $log); } if ($endln) { $string .= "\r\n"; } $res = fwrite($this->fp, $string); if ($res === false) { $this->closeSocket(); } return $res; } /** * Send command to the connection stream with Command Continuation * Requests (RFC3501 7.5) and LITERAL+ (RFC2088) support * * @param string $string Command string * @param bool $endln True if CRLF need to be added at the end of command * @param bool $anonymized Don't write the given data to log but a placeholder * * @return int|bool Number of bytes sent, False on error */ protected function putLineC($string, $endln=true, $anonymized=false) { if (!$this->fp) { return false; } if ($endln) { $string .= "\r\n"; } $res = 0; if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) { for ($i=0, $cnt=count($parts); $i<$cnt; $i++) { if ($i+1 < $cnt && preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i+1], $matches)) { // LITERAL+ support if ($this->prefs['literal+']) { $parts[$i+1] = sprintf("{%d+}\r\n", $matches[1]); } $bytes = $this->putLine($parts[$i].$parts[$i+1], false, $anonymized); if ($bytes === false) { return false; } $res += $bytes; // don't wait if server supports LITERAL+ capability if (!$this->prefs['literal+']) { $line = $this->readLine(1000); // handle error in command if ($line[0] != '+') { return false; } } $i++; } else { $bytes = $this->putLine($parts[$i], false, $anonymized); if ($bytes === false) { return false; } $res += $bytes; } } } return $res; } /** * Reads line from the connection stream * * @param int $size Buffer size * * @return string Line of text response */ protected function readLine($size = 1024) { $line = ''; if (!$size) { $size = 1024; } do { if ($this->eof()) { return $line ?: null; } $buffer = fgets($this->fp, $size); if ($buffer === false) { $this->closeSocket(); break; } if ($this->debug) { $this->debug('S: '. rtrim($buffer)); } $line .= $buffer; } while (substr($buffer, -1) != "\n"); return $line; } /** * Reads more data from the connection stream when provided * data contain string literal * * @param string $line Response text * @param bool $escape Enables escaping * * @return string Line of text response */ protected function multLine($line, $escape = false) { $line = rtrim($line); if (preg_match('/\{([0-9]+)\}$/', $line, $m)) { $out = ''; $str = substr($line, 0, -strlen($m[0])); $bytes = $m[1]; while (strlen($out) < $bytes) { $line = $this->readBytes($bytes); if ($line === null) { break; } $out .= $line; } $line = $str . ($escape ? $this->escape($out) : $out); } return $line; } /** * Reads specified number of bytes from the connection stream * * @param int $bytes Number of bytes to get * * @return string Response text */ protected function readBytes($bytes) { $data = ''; $len = 0; while ($len < $bytes && !$this->eof()) { $d = fread($this->fp, $bytes-$len); if ($this->debug) { $this->debug('S: '. $d); } $data .= $d; $data_len = strlen($data); if ($len == $data_len) { break; // nothing was read -> exit to avoid apache lockups } $len = $data_len; } return $data; } /** * Reads complete response to the IMAP command * * @param array $untagged Will be filled with untagged response lines * * @return string Response text */ protected function readReply(&$untagged = null) { do { $line = trim($this->readLine(1024)); // store untagged response lines if ($line[0] == '*') { $untagged[] = $line; } } while ($line[0] == '*'); if ($untagged) { $untagged = implode("\n", $untagged); } return $line; } /** * Response parser. * * @param string $string Response text * @param string $err_prefix Error message prefix * * @return int Response status */ protected function parseResult($string, $err_prefix = '') { if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) { $res = strtoupper($matches[1]); $str = trim($matches[2]); if ($res == 'OK') { $this->errornum = self::ERROR_OK; } else if ($res == 'NO') { $this->errornum = self::ERROR_NO; } else if ($res == 'BAD') { $this->errornum = self::ERROR_BAD; } else if ($res == 'BYE') { $this->closeSocket(); $this->errornum = self::ERROR_BYE; } if ($str) { $str = trim($str); // get response string and code (RFC5530) if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) { $this->resultcode = strtoupper($m[1]); $str = trim(substr($str, strlen($m[1]) + 2)); } else { $this->resultcode = null; // parse response for [APPENDUID 1204196876 3456] if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) { $this->data['APPENDUID'] = $m[1]; } // parse response for [COPYUID 1204196876 3456:3457 123:124] else if (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) { $this->data['COPYUID'] = array($m[1], $m[2]); } } $this->result = $str; if ($this->errornum != self::ERROR_OK) { $this->error = $err_prefix ? $err_prefix.$str : $str; } } return $this->errornum; } return self::ERROR_UNKNOWN; } /** * Checks connection stream state. * * @return bool True if connection is closed */ protected function eof() { if (!is_resource($this->fp)) { return true; } // If a connection opened by fsockopen() wasn't closed // by the server, feof() will hang. $start = microtime(true); if (feof($this->fp) || ($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout'])) ) { $this->closeSocket(); return true; } return false; } /** * Closes connection stream. */ protected function closeSocket() { @fclose($this->fp); $this->fp = null; } /** * Error code/message setter. */ protected function setError($code, $msg = '') { $this->errornum = $code; $this->error = $msg; return $code; } /** * Checks response status. * Checks if command response line starts with specified prefix (or * BYE/BAD) * * @param string $string Response text * @param string $match Prefix to match with (case-sensitive) * @param bool $error Enables BYE/BAD checking * @param bool $nonempty Enables empty response checking * * @return bool True any check is true or connection is closed. */ protected function startsWith($string, $match, $error = false, $nonempty = false) { if (!$this->fp) { return true; } if (strncmp($string, $match, strlen($match)) == 0) { return true; } if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) { if (strtoupper($m[1]) == 'BYE') { $this->closeSocket(); } return true; } if ($nonempty && !strlen($string)) { return true; } return false; } /** * Capabilities checker */ protected function hasCapability($name) { if (empty($this->capability) || $name == '') { return false; } if (in_array($name, $this->capability)) { return true; } else if (strpos($name, '=')) { return false; } $result = array(); foreach ($this->capability as $cap) { $entry = explode('=', $cap); if ($entry[0] == $name) { $result[] = $entry[1]; } } return $result ?: false; } /** * Capabilities checker * * @param string $name Capability name * * @return mixed Capability values array for key=value pairs, true/false for others */ public function getCapability($name) { $result = $this->hasCapability($name); if (!empty($result)) { return $result; } else if ($this->capability_readed) { return false; } // get capabilities (only once) because initial // optional CAPABILITY response may differ $result = $this->execute('CAPABILITY'); if ($result[0] == self::ERROR_OK) { $this->parseCapability($result[1]); } $this->capability_readed = true; return $this->hasCapability($name); } /** * Clears detected server capabilities */ public function clearCapability() { $this->capability = array(); $this->capability_readed = false; } /** * DIGEST-MD5/CRAM-MD5/PLAIN Authentication * * @param string $user Username * @param string $pass Password * @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5) * * @return resource Connection resourse on success, error code on error */ protected function authenticate($user, $pass, $type = 'PLAIN') { if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') { if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) { return $this->setError(self::ERROR_BYE, "The Auth_SASL package is required for DIGEST-MD5 authentication"); } $this->putLine($this->nextTag() . " AUTHENTICATE $type"); $line = trim($this->readReply()); if ($line[0] == '+') { $challenge = substr($line, 2); } else { return $this->parseResult($line); } if ($type == 'CRAM-MD5') { // RFC2195: CRAM-MD5 $ipad = ''; $opad = ''; $xor = function($str1, $str2) { $result = ''; $size = strlen($str1); for ($i=0; $i<$size; $i++) { $result .= chr(ord($str1[$i]) ^ ord($str2[$i])); } return $result; }; // initialize ipad, opad for ($i=0; $i<64; $i++) { $ipad .= chr(0x36); $opad .= chr(0x5C); } // pad $pass so it's 64 bytes $pass = str_pad($pass, 64, chr(0)); // generate hash $hash = md5($xor($pass, $opad) . pack("H*", md5($xor($pass, $ipad) . base64_decode($challenge)))); $reply = base64_encode($user . ' ' . $hash); // send result $this->putLine($reply, true, true); } else { // RFC2831: DIGEST-MD5 // proxy authorization if (!empty($this->prefs['auth_cid'])) { $authc = $this->prefs['auth_cid']; $pass = $this->prefs['auth_pw']; } else { $authc = $user; $user = ''; } $auth_sasl = new Auth_SASL; $auth_sasl = $auth_sasl->factory('digestmd5'); $reply = base64_encode($auth_sasl->getResponse($authc, $pass, base64_decode($challenge), $this->host, 'imap', $user)); // send result $this->putLine($reply, true, true); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } // check response $challenge = substr($line, 2); $challenge = base64_decode($challenge); if (strpos($challenge, 'rspauth=') === false) { return $this->setError(self::ERROR_BAD, "Unexpected response from server to DIGEST-MD5 response"); } $this->putLine(''); } $line = $this->readReply(); $result = $this->parseResult($line); } else if ($type == 'GSSAPI') { if (!extension_loaded('krb5')) { return $this->setError(self::ERROR_BYE, "The krb5 extension is required for GSSAPI authentication"); } if (empty($this->prefs['gssapi_cn'])) { return $this->setError(self::ERROR_BYE, "The gssapi_cn parameter is required for GSSAPI authentication"); } if (empty($this->prefs['gssapi_context'])) { return $this->setError(self::ERROR_BYE, "The gssapi_context parameter is required for GSSAPI authentication"); } putenv('KRB5CCNAME=' . $this->prefs['gssapi_cn']); try { $ccache = new KRB5CCache(); $ccache->open($this->prefs['gssapi_cn']); $gssapicontext = new GSSAPIContext(); $gssapicontext->acquireCredentials($ccache); $token = ''; $success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], null, null, null, $token); $token = base64_encode($token); } catch (Exception $e) { trigger_error($e->getMessage(), E_USER_WARNING); return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed"); } $this->putLine($this->nextTag() . " AUTHENTICATE GSSAPI " . $token); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } try { $itoken = base64_decode(substr($line, 2)); if (!$gssapicontext->unwrap($itoken, $itoken)) { throw new Exception("GSSAPI SASL input token unwrap failed"); } if (strlen($itoken) < 4) { throw new Exception("GSSAPI SASL input token invalid"); } // Integrity/encryption layers are not supported. The first bit // indicates that the server supports "no security layers". // 0x00 should not occur, but support broken implementations. $server_layers = ord($itoken[0]); if ($server_layers && ($server_layers & 0x1) != 0x1) { throw new Exception("Server requires GSSAPI SASL integrity/encryption"); } // Construct output token. 0x01 in the first octet = SASL layer "none", // zero in the following three octets = no data follows. // See https://github.com/cyrusimap/cyrus-sasl/blob/e41cfb986c1b1935770de554872247453fdbb079/plugins/gssapi.c#L1284 if (!$gssapicontext->wrap(pack("CCCC", 0x1, 0, 0, 0), $otoken, true)) { throw new Exception("GSSAPI SASL output token wrap failed"); } } catch (Exception $e) { trigger_error($e->getMessage(), E_USER_WARNING); return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed"); } $this->putLine(base64_encode($otoken)); $line = $this->readReply(); $result = $this->parseResult($line); } else if ($type == 'PLAIN') { // proxy authorization if (!empty($this->prefs['auth_cid'])) { $authc = $this->prefs['auth_cid']; $pass = $this->prefs['auth_pw']; } else { $authc = $user; $user = ''; } $reply = base64_encode($user . chr(0) . $authc . chr(0) . $pass); // RFC 4959 (SASL-IR): save one round trip if ($this->getCapability('SASL-IR')) { list($result, $line) = $this->execute("AUTHENTICATE PLAIN", array($reply), self::COMMAND_LASTLINE | self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED); } else { $this->putLine($this->nextTag() . " AUTHENTICATE PLAIN"); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } // send result, get reply and process it $this->putLine($reply, true, true); $line = $this->readReply(); $result = $this->parseResult($line); } } else if ($type == 'LOGIN') { $this->putLine($this->nextTag() . " AUTHENTICATE LOGIN"); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } $this->putLine(base64_encode($user), true, true); $line = trim($this->readReply()); if ($line[0] != '+') { return $this->parseResult($line); } // send result, get reply and process it $this->putLine(base64_encode($pass), true, true); $line = $this->readReply(); $result = $this->parseResult($line); } if ($result === self::ERROR_OK) { // optional CAPABILITY response if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) { $this->parseCapability($matches[1], true); } return $this->fp; } return $this->setError($result, "AUTHENTICATE $type: $line"); } /** * LOGIN Authentication * * @param string $user Username * @param string $pass Password * * @return resource Connection resourse on success, error code on error */ protected function login($user, $password) { // Prevent from sending credentials in plain text when connection is not secure if ($this->getCapability('LOGINDISABLED')) { return $this->setError(self::ERROR_BAD, "Login disabled by IMAP server"); } list($code, $response) = $this->execute('LOGIN', array( $this->escape($user), $this->escape($password)), self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED); // re-set capabilities list if untagged CAPABILITY response provided if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) { $this->parseCapability($matches[1], true); } if ($code == self::ERROR_OK) { return $this->fp; } return $code; } /** * Detects hierarchy delimiter * * @return string The delimiter */ public function getHierarchyDelimiter() { if (isset($this->prefs['delimiter'])) { return $this->prefs['delimiter']; } // try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8) list($code, $response) = $this->execute('LIST', array($this->escape(''), $this->escape(''))); if ($code == self::ERROR_OK) { $args = $this->tokenizeResponse($response, 4); $delimiter = $args[3]; if (strlen($delimiter) > 0) { return ($this->prefs['delimiter'] = $delimiter); } } } /** * NAMESPACE handler (RFC 2342) * * @return array Namespace data hash (personal, other, shared) */ public function getNamespace() { if (array_key_exists('namespace', $this->prefs)) { return $this->prefs['namespace']; } if (!$this->getCapability('NAMESPACE')) { return self::ERROR_BAD; } list($code, $response) = $this->execute('NAMESPACE'); if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) { $response = substr($response, 11); $data = $this->tokenizeResponse($response); } if (!is_array($data)) { return $code; } $this->prefs['namespace'] = array( 'personal' => $data[0], 'other' => $data[1], 'shared' => $data[2], ); return $this->prefs['namespace']; } /** * Connects to IMAP server and authenticates. * * @param string $host Server hostname or IP * @param string $user User name * @param string $password Password * @param array $options Connection and class options * * @return bool True on success, False on failure */ public function connect($host, $user, $password, $options = array()) { // configure $this->set_prefs($options); $this->host = $host; $this->user = $user; $this->logged = false; $this->selected = null; // check input if (empty($host)) { $this->setError(self::ERROR_BAD, "Empty host"); return false; } if (empty($user)) { $this->setError(self::ERROR_NO, "Empty user"); return false; } if (empty($password) && empty($options['gssapi_cn'])) { $this->setError(self::ERROR_NO, "Empty password"); return false; } // Connect if (!$this->_connect($host)) { return false; } // Send ID info if (!empty($this->prefs['ident']) && $this->getCapability('ID')) { $this->data['ID'] = $this->id($this->prefs['ident']); } $auth_method = $this->prefs['auth_type']; $auth_methods = array(); $result = null; // check for supported auth methods if (!$auth_method || $auth_method == 'CHECK') { if ($auth_caps = $this->getCapability('AUTH')) { $auth_methods = $auth_caps; } // Use best (for security) supported authentication method $all_methods = array('DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN'); if (!empty($this->prefs['gssapi_cn'])) { array_unshift($all_methods, 'GSSAPI'); } foreach ($all_methods as $auth_method) { if (in_array($auth_method, $auth_methods)) { break; } } // Prefer LOGIN over AUTHENTICATE LOGIN for performance reasons if ($auth_method == 'LOGIN' && !$this->getCapability('LOGINDISABLED')) { $auth_method = 'IMAP'; } } // pre-login capabilities can be not complete $this->capability_readed = false; // Authenticate switch ($auth_method) { case 'CRAM_MD5': $auth_method = 'CRAM-MD5'; case 'CRAM-MD5': case 'DIGEST-MD5': case 'GSSAPI': case 'PLAIN': case 'LOGIN': $result = $this->authenticate($user, $password, $auth_method); break; case 'IMAP': $result = $this->login($user, $password); break; default: $this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method"); } // Connected and authenticated if (is_resource($result)) { if (!empty($this->prefs['force_caps'])) { $this->clearCapability(); } $this->logged = true; return true; } $this->closeConnection(); return false; } /** * Connects to IMAP server. * * @param string $host Server hostname or IP * * @return bool True on success, False on failure */ protected function _connect($host) { // initialize connection $this->error = ''; $this->errornum = self::ERROR_OK; if (!$this->prefs['port']) { $this->prefs['port'] = 143; } // check for SSL if (!empty($this->prefs['ssl_mode']) && $this->prefs['ssl_mode'] != 'tls') { $host = $this->prefs['ssl_mode'] . '://' . $host; } if (empty($this->prefs['timeout']) || $this->prefs['timeout'] < 0) { $this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout'))); } if ($this->debug) { // set connection identifier for debug output $this->resourceid = strtoupper(substr(md5(microtime() . $host . $this->user), 0, 4)); $_host = ($this->prefs['ssl_mode'] == 'tls' ? 'tls://' : '') . $host . ':' . $this->prefs['port']; $this->debug("Connecting to $_host..."); } if (!empty($this->prefs['socket_options'])) { $context = stream_context_create($this->prefs['socket_options']); $this->fp = stream_socket_client($host . ':' . $this->prefs['port'], $errno, $errstr, $this->prefs['timeout'], STREAM_CLIENT_CONNECT, $context); } else { $this->fp = @fsockopen($host, $this->prefs['port'], $errno, $errstr, $this->prefs['timeout']); } if (!$this->fp) { $this->setError(self::ERROR_BAD, sprintf("Could not connect to %s:%d: %s", $host, $this->prefs['port'], $errstr ?: "Unknown reason")); return false; } if ($this->prefs['timeout'] > 0) { stream_set_timeout($this->fp, $this->prefs['timeout']); } $line = trim(fgets($this->fp, 8192)); if ($this->debug && $line) { $this->debug('S: '. $line); } // Connected to wrong port or connection error? if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) { if ($line) $error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $this->prefs['port'], $line); else $error = sprintf("Empty startup greeting (%s:%d)", $host, $this->prefs['port']); $this->setError(self::ERROR_BAD, $error); $this->closeConnection(); return false; } $this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line)); // RFC3501 [7.1] optional CAPABILITY response if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) { $this->parseCapability($matches[1], true); } // TLS connection if ($this->prefs['ssl_mode'] == 'tls' && $this->getCapability('STARTTLS')) { $res = $this->execute('STARTTLS'); if ($res[0] != self::ERROR_OK) { $this->closeConnection(); return false; } if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) { $crypto_method = $this->prefs['socket_options']['ssl']['crypto_method']; } else { // There is no flag to enable all TLS methods. Net_SMTP // handles enabling TLS similarly. $crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT | @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; } if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) { $this->setError(self::ERROR_BAD, "Unable to negotiate TLS"); $this->closeConnection(); return false; } // Now we're secure, capabilities need to be reread $this->clearCapability(); } return true; } /** * Initializes environment */ protected function set_prefs($prefs) { // set preferences if (is_array($prefs)) { $this->prefs = $prefs; } // set auth method if (!empty($this->prefs['auth_type'])) { $this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']); } else { $this->prefs['auth_type'] = 'CHECK'; } // disabled capabilities if (!empty($this->prefs['disabled_caps'])) { $this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']); } // additional message flags if (!empty($this->prefs['message_flags'])) { $this->flags = array_merge($this->flags, $this->prefs['message_flags']); unset($this->prefs['message_flags']); } } /** * Checks connection status * * @return bool True if connection is active and user is logged in, False otherwise. */ public function connected() { return $this->fp && $this->logged; } /** * Closes connection with logout. */ public function closeConnection() { if ($this->logged && $this->putLine($this->nextTag() . ' LOGOUT')) { $this->readReply(); } $this->closeSocket(); } /** * Executes SELECT command (if mailbox is already not in selected state) * * @param string $mailbox Mailbox name * @param array $qresync_data QRESYNC data (RFC5162) * * @return boolean True on success, false on error */ public function select($mailbox, $qresync_data = null) { if (!strlen($mailbox)) { return false; } if ($this->selected === $mailbox) { return true; } $params = array($this->escape($mailbox)); // QRESYNC data items // 0. the last known UIDVALIDITY, // 1. the last known modification sequence, // 2. the optional set of known UIDs, and // 3. an optional parenthesized list of known sequence ranges and their // corresponding UIDs. if (!empty($qresync_data)) { if (!empty($qresync_data[2])) { $qresync_data[2] = self::compressMessageSet($qresync_data[2]); } $params[] = array('QRESYNC', $qresync_data); } list($code, $response) = $this->execute('SELECT', $params); if ($code == self::ERROR_OK) { $this->clear_mailbox_cache(); $response = explode("\r\n", $response); foreach ($response as $line) { if (preg_match('/^\* OK \[/i', $line)) { $pos = strcspn($line, ' ]', 6); $token = strtoupper(substr($line, 6, $pos)); $pos += 7; switch ($token) { case 'UIDNEXT': case 'UIDVALIDITY': case 'UNSEEN': if ($len = strspn($line, '0123456789', $pos)) { $this->data[$token] = (int) substr($line, $pos, $len); } break; case 'HIGHESTMODSEQ': if ($len = strspn($line, '0123456789', $pos)) { $this->data[$token] = (string) substr($line, $pos, $len); } break; case 'NOMODSEQ': $this->data[$token] = true; break; case 'PERMANENTFLAGS': $start = strpos($line, '(', $pos); $end = strrpos($line, ')'); if ($start && $end) { $flags = substr($line, $start + 1, $end - $start - 1); $this->data[$token] = explode(' ', $flags); } break; } } else if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) { $token = strtoupper($match[2]); switch ($token) { case 'EXISTS': case 'RECENT': $this->data[$token] = (int) $match[1]; break; case 'FETCH': // QRESYNC FETCH response (RFC5162) $line = substr($line, strlen($match[0])); $fetch_data = $this->tokenizeResponse($line, 1); $data = array('id' => $match[1]); for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) { $data[strtolower($fetch_data[$i])] = $fetch_data[$i+1]; } $this->data['QRESYNC'][$data['uid']] = $data; break; } } // QRESYNC VANISHED response (RFC5162) else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) { $line = substr($line, strlen($match[0])); $v_data = $this->tokenizeResponse($line, 1); $this->data['VANISHED'] = $v_data; } } $this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY'; $this->selected = $mailbox; return true; } return false; } /** * Executes STATUS command * * @param string $mailbox Mailbox name * @param array $items Additional requested item names. By default * MESSAGES and UNSEEN are requested. Other defined * in RFC3501: UIDNEXT, UIDVALIDITY, RECENT * * @return array Status item-value hash * @since 0.5-beta */ public function status($mailbox, $items = array()) { if (!strlen($mailbox)) { return false; } if (!in_array('MESSAGES', $items)) { $items[] = 'MESSAGES'; } if (!in_array('UNSEEN', $items)) { $items[] = 'UNSEEN'; } list($code, $response) = $this->execute('STATUS', array($this->escape($mailbox), '(' . implode(' ', $items) . ')'), 0, '/^\* STATUS /i'); if ($code == self::ERROR_OK && $response) { $result = array(); $response = substr($response, 9); // remove prefix "* STATUS " list($mbox, $items) = $this->tokenizeResponse($response, 2); // Fix for #1487859. Some buggy server returns not quoted // folder name with spaces. Let's try to handle this situation if (!is_array($items) && ($pos = strpos($response, '(')) !== false) { $response = substr($response, $pos); $items = $this->tokenizeResponse($response, 1); } if (!is_array($items)) { return $result; } for ($i=0, $len=count($items); $i<$len; $i += 2) { $result[$items[$i]] = $items[$i+1]; } $this->data['STATUS:'.$mailbox] = $result; return $result; } return false; } /** * Executes EXPUNGE command * * @param string $mailbox Mailbox name * @param string|array $messages Message UIDs to expunge * * @return boolean True on success, False on error */ public function expunge($mailbox, $messages = null) { if (!$this->select($mailbox)) { return false; } if (!$this->data['READ-WRITE']) { $this->setError(self::ERROR_READONLY, "Mailbox is read-only"); return false; } // Clear internal status cache $this->clear_status_cache($mailbox); if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) { $messages = self::compressMessageSet($messages); $result = $this->execute('UID EXPUNGE', array($messages), self::COMMAND_NORESPONSE); } else { $result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE); } if ($result == self::ERROR_OK) { $this->selected = null; // state has changed, need to reselect return true; } return false; } /** * Executes CLOSE command * * @return boolean True on success, False on error * @since 0.5 */ public function close() { $result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE); if ($result == self::ERROR_OK) { $this->selected = null; return true; } return false; } /** * Folder subscription (SUBSCRIBE) * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function subscribe($mailbox) { $result = $this->execute('SUBSCRIBE', array($this->escape($mailbox)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Folder unsubscription (UNSUBSCRIBE) * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function unsubscribe($mailbox) { $result = $this->execute('UNSUBSCRIBE', array($this->escape($mailbox)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Folder creation (CREATE) * * @param string $mailbox Mailbox name * @param array $types Optional folder types (RFC 6154) * * @return bool True on success, False on error */ public function createFolder($mailbox, $types = null) { $args = array($this->escape($mailbox)); // RFC 6154: CREATE-SPECIAL-USE if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) { $args[] = '(USE (' . implode(' ', $types) . '))'; } $result = $this->execute('CREATE', $args, self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Folder renaming (RENAME) * * @param string $mailbox Mailbox name * * @return bool True on success, False on error */ public function renameFolder($from, $to) { $result = $this->execute('RENAME', array($this->escape($from), $this->escape($to)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Executes DELETE command * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function deleteFolder($mailbox) { $result = $this->execute('DELETE', array($this->escape($mailbox)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Removes all messages in a folder * * @param string $mailbox Mailbox name * * @return boolean True on success, False on error */ public function clearFolder($mailbox) { $res = false; if ($this->countMessages($mailbox) > 0) { $res = $this->flag($mailbox, '1:*', 'DELETED'); } if ($res) { if ($this->selected === $mailbox) { $res = $this->close(); } else { $res = $this->expunge($mailbox); } } return $res; } /** * Returns list of mailboxes * * @param string $ref Reference name * @param string $mailbox Mailbox name * @param array $return_opts (see self::_listMailboxes) * @param array $select_opts (see self::_listMailboxes) * * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response * is requested, False on error. */ public function listMailboxes($ref, $mailbox, $return_opts = array(), $select_opts = array()) { return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts); } /** * Returns list of subscribed mailboxes * * @param string $ref Reference name * @param string $mailbox Mailbox name * @param array $return_opts (see self::_listMailboxes) * * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response * is requested, False on error. */ public function listSubscribed($ref, $mailbox, $return_opts = array()) { return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null); } /** * IMAP LIST/LSUB command * * @param string $ref Reference name * @param string $mailbox Mailbox name * @param bool $subscribed Enables returning subscribed mailboxes only * @param array $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED) * Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN, * MYRIGHTS, SUBSCRIBED, CHILDREN * @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED) * Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE, * SPECIAL-USE (RFC6154) * * @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response * is requested, False on error. */ protected function _listMailboxes($ref, $mailbox, $subscribed=false, $return_opts=array(), $select_opts=array()) { if (!strlen($mailbox)) { $mailbox = '*'; } $args = array(); $rets = array(); if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) { $select_opts = (array) $select_opts; $args[] = '(' . implode(' ', $select_opts) . ')'; } $lstatus = false; $args[] = $this->escape($ref); $args[] = $this->escape($mailbox); if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) { $ext_opts = array('SUBSCRIBED', 'CHILDREN'); $rets = array_intersect($return_opts, $ext_opts); $return_opts = array_diff($return_opts, $rets); } if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) { $lstatus = true; $status_opts = array('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN'); $opts = array_diff($return_opts, $status_opts); $status_opts = array_diff($return_opts, $opts); if (!empty($status_opts)) { $rets[] = 'STATUS (' . implode(' ', $status_opts) . ')'; } if (!empty($opts)) { $rets = array_merge($rets, $opts); } } if (!empty($rets)) { $args[] = 'RETURN (' . implode(' ', $rets) . ')'; } list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args); if ($code == self::ERROR_OK) { $folders = array(); $last = 0; $pos = 0; $response .= "\r\n"; while ($pos = strpos($response, "\r\n", $pos+1)) { // literal string, not real end-of-command-line if ($response[$pos-1] == '}') { continue; } $line = substr($response, $last, $pos - $last); $last = $pos + 2; if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) { continue; } $cmd = strtoupper($m[1]); $line = substr($line, strlen($m[0])); // * LIST () if ($cmd == 'LIST' || $cmd == 'LSUB') { list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3); // Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879) if ($delim) { $mailbox = rtrim($mailbox, $delim); } // Add to result array if (!$lstatus) { $folders[] = $mailbox; } else { $folders[$mailbox] = array(); } // store folder options if ($cmd == 'LIST') { // Add to options array if (empty($this->data['LIST'][$mailbox])) { $this->data['LIST'][$mailbox] = $opts; } else if (!empty($opts)) { $this->data['LIST'][$mailbox] = array_unique(array_merge( $this->data['LIST'][$mailbox], $opts)); } } } else if ($lstatus) { // * STATUS () if ($cmd == 'STATUS') { list($mailbox, $status) = $this->tokenizeResponse($line, 2); for ($i=0, $len=count($status); $i<$len; $i += 2) { list($name, $value) = $this->tokenizeResponse($status, 2); $folders[$mailbox][$name] = $value; } } // * MYRIGHTS else if ($cmd == 'MYRIGHTS') { list($mailbox, $acl) = $this->tokenizeResponse($line, 2); $folders[$mailbox]['MYRIGHTS'] = $acl; } } } return $folders; } return false; } /** * Returns count of all messages in a folder * * @param string $mailbox Mailbox name * * @return int Number of messages, False on error */ public function countMessages($mailbox) { if ($this->selected === $mailbox && isset($this->data['EXISTS'])) { return $this->data['EXISTS']; } if (isset($this->data["STATUS:".$mailbox])) { $cache = $this->data["STATUS:".$mailbox]; if (!empty($cache) && isset($cache['MESSAGES'])) { return (int) $cache['MESSAGES']; } } // Try STATUS (should be faster than SELECT) $counts = $this->status($mailbox); if (is_array($counts)) { return (int) $counts['MESSAGES']; } return false; } /** * Returns count of messages with \Recent flag in a folder * * @param string $mailbox Mailbox name * * @return int Number of messages, False on error */ public function countRecent($mailbox) { if ($this->selected === $mailbox && isset($this->data['RECENT'])) { return $this->data['RECENT']; } // Check internal cache $cache = $this->data['STATUS:'.$mailbox]; if (!empty($cache) && isset($cache['RECENT'])) { return (int) $cache['RECENT']; } // Try STATUS (should be faster than SELECT) $counts = $this->status($mailbox, array('RECENT')); if (is_array($counts)) { return (int) $counts['RECENT']; } return false; } /** * Returns count of messages without \Seen flag in a specified folder * * @param string $mailbox Mailbox name * * @return int Number of messages, False on error */ public function countUnseen($mailbox) { // Check internal cache $cache = $this->data['STATUS:'.$mailbox]; if (!empty($cache) && isset($cache['UNSEEN'])) { return (int) $cache['UNSEEN']; } // Try STATUS (should be faster than SELECT+SEARCH) $counts = $this->status($mailbox); if (is_array($counts)) { return (int) $counts['UNSEEN']; } // Invoke SEARCH as a fallback $index = $this->search($mailbox, 'ALL UNSEEN', false, array('COUNT')); if (!$index->is_error()) { return $index->count(); } return false; } /** * Executes ID command (RFC2971) * * @param array $items Client identification information key/value hash * * @return array Server identification information key/value hash * @since 0.6 */ public function id($items = array()) { if (is_array($items) && !empty($items)) { foreach ($items as $key => $value) { $args[] = $this->escape($key, true); $args[] = $this->escape($value, true); } } list($code, $response) = $this->execute('ID', array(!empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null)), 0, '/^\* ID /i'); if ($code == self::ERROR_OK && $response) { $response = substr($response, 5); // remove prefix "* ID " $items = $this->tokenizeResponse($response, 1); $result = null; for ($i=0, $len=count($items); $i<$len; $i += 2) { $result[$items[$i]] = $items[$i+1]; } return $result; } return false; } /** * Executes ENABLE command (RFC5161) * * @param mixed $extension Extension name to enable (or array of names) * * @return array|bool List of enabled extensions, False on error * @since 0.6 */ public function enable($extension) { if (empty($extension)) { return false; } if (!$this->hasCapability('ENABLE')) { return false; } if (!is_array($extension)) { $extension = array($extension); } if (!empty($this->extensions_enabled)) { // check if all extensions are already enabled $diff = array_diff($extension, $this->extensions_enabled); if (empty($diff)) { return $extension; } // Make sure the mailbox isn't selected, before enabling extension(s) if ($this->selected !== null) { $this->close(); } } list($code, $response) = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i'); if ($code == self::ERROR_OK && $response) { $response = substr($response, 10); // remove prefix "* ENABLED " $result = (array) $this->tokenizeResponse($response); $this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result)); return $this->extensions_enabled; } return false; } /** * Executes SORT command * * @param string $mailbox Mailbox name * @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) * @param string $criteria Searching criteria * @param bool $return_uid Enables UID SORT usage * @param string $encoding Character set * * @return rcube_result_index Response data */ public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII') { $old_sel = $this->selected; $supported = array('ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO'); $field = strtoupper($field); if ($field == 'INTERNALDATE') { $field = 'ARRIVAL'; } if (!in_array($field, $supported)) { return new rcube_result_index($mailbox); } if (!$this->select($mailbox)) { return new rcube_result_index($mailbox); } // return empty result when folder is empty and we're just after SELECT if ($old_sel != $mailbox && !$this->data['EXISTS']) { return new rcube_result_index($mailbox, '* SORT'); } // RFC 5957: SORT=DISPLAY if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) { $field = 'DISPLAY' . $field; } $encoding = $encoding ? trim($encoding) : 'US-ASCII'; $criteria = $criteria ? 'ALL ' . trim($criteria) : 'ALL'; list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT', array("($field)", $encoding, $criteria)); if ($code != self::ERROR_OK) { $response = null; } return new rcube_result_index($mailbox, $response); } /** * Executes THREAD command * * @param string $mailbox Mailbox name * @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS) * @param string $criteria Searching criteria * @param bool $return_uid Enables UIDs in result instead of sequence numbers * @param string $encoding Character set * * @return rcube_result_thread Thread data */ public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII') { $old_sel = $this->selected; if (!$this->select($mailbox)) { return new rcube_result_thread($mailbox); } // return empty result when folder is empty and we're just after SELECT if ($old_sel != $mailbox && !$this->data['EXISTS']) { return new rcube_result_thread($mailbox, '* THREAD'); } $encoding = $encoding ? trim($encoding) : 'US-ASCII'; $algorithm = $algorithm ? trim($algorithm) : 'REFERENCES'; $criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL'; list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD', array($algorithm, $encoding, $criteria)); if ($code != self::ERROR_OK) { $response = null; } return new rcube_result_thread($mailbox, $response); } /** * Executes SEARCH command * * @param string $mailbox Mailbox name * @param string $criteria Searching criteria * @param bool $return_uid Enable UID in result instead of sequence ID * @param array $items Return items (MIN, MAX, COUNT, ALL) * * @return rcube_result_index Result data */ public function search($mailbox, $criteria, $return_uid = false, $items = array()) { $old_sel = $this->selected; if (!$this->select($mailbox)) { return new rcube_result_index($mailbox); } // return empty result when folder is empty and we're just after SELECT if ($old_sel != $mailbox && !$this->data['EXISTS']) { return new rcube_result_index($mailbox, '* SEARCH'); } // If ESEARCH is supported always use ALL // but not when items are specified or using simple id2uid search if (empty($items) && preg_match('/[^0-9]/', $criteria)) { $items = array('ALL'); } $esearch = empty($items) ? false : $this->getCapability('ESEARCH'); $criteria = trim($criteria); $params = ''; // RFC4731: ESEARCH if (!empty($items) && $esearch) { $params .= 'RETURN (' . implode(' ', $items) . ')'; } if (!empty($criteria)) { $params .= ($params ? ' ' : '') . $criteria; } else { $params .= 'ALL'; } list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH', array($params)); if ($code != self::ERROR_OK) { $response = null; } return new rcube_result_index($mailbox, $response); } /** * Simulates SORT command by using FETCH and sorting. * * @param string $mailbox Mailbox name * @param string|array $message_set Searching criteria (list of messages to return) * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) * @param bool $skip_deleted Makes that DELETED messages will be skipped * @param bool $uidfetch Enables UID FETCH usage * @param bool $return_uid Enables returning UIDs instead of IDs * * @return rcube_result_index Response data */ public function index($mailbox, $message_set, $index_field='', $skip_deleted=true, $uidfetch=false, $return_uid=false) { $msg_index = $this->fetchHeaderIndex($mailbox, $message_set, $index_field, $skip_deleted, $uidfetch, $return_uid); if (!empty($msg_index)) { asort($msg_index); // ASC $msg_index = array_keys($msg_index); $msg_index = '* SEARCH ' . implode(' ', $msg_index); } else { $msg_index = is_array($msg_index) ? '* SEARCH' : null; } return new rcube_result_index($mailbox, $msg_index); } /** * Fetches specified header/data value for a set of messages. * * @param string $mailbox Mailbox name * @param string|array $message_set Searching criteria (list of messages to return) * @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO) * @param bool $skip_deleted Makes that DELETED messages will be skipped * @param bool $uidfetch Enables UID FETCH usage * @param bool $return_uid Enables returning UIDs instead of IDs * * @return array|bool List of header values or False on failure */ public function fetchHeaderIndex($mailbox, $message_set, $index_field = '', $skip_deleted = true, $uidfetch = false, $return_uid = false) { if (is_array($message_set)) { if (!($message_set = $this->compressMessageSet($message_set))) { return false; } } else { list($from_idx, $to_idx) = explode(':', $message_set); if (empty($message_set) || (isset($to_idx) && $to_idx != '*' && (int)$from_idx > (int)$to_idx) ) { return false; } } $index_field = empty($index_field) ? 'DATE' : strtoupper($index_field); $fields_a['DATE'] = 1; $fields_a['INTERNALDATE'] = 4; $fields_a['ARRIVAL'] = 4; $fields_a['FROM'] = 1; $fields_a['REPLY-TO'] = 1; $fields_a['SENDER'] = 1; $fields_a['TO'] = 1; $fields_a['CC'] = 1; $fields_a['SUBJECT'] = 1; $fields_a['UID'] = 2; $fields_a['SIZE'] = 2; $fields_a['SEEN'] = 3; $fields_a['RECENT'] = 3; $fields_a['DELETED'] = 3; if (!($mode = $fields_a[$index_field])) { return false; } // Select the mailbox if (!$this->select($mailbox)) { return false; } // build FETCH command string $key = $this->nextTag(); $cmd = $uidfetch ? 'UID FETCH' : 'FETCH'; $fields = array(); if ($return_uid) { $fields[] = 'UID'; } if ($skip_deleted) { $fields[] = 'FLAGS'; } if ($mode == 1) { if ($index_field == 'DATE') { $fields[] = 'INTERNALDATE'; } $fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]"; } else if ($mode == 2) { if ($index_field == 'SIZE') { $fields[] = 'RFC822.SIZE'; } else if (!$return_uid || $index_field != 'UID') { $fields[] = $index_field; } } else if ($mode == 3 && !$skip_deleted) { $fields[] = 'FLAGS'; } else if ($mode == 4) { $fields[] = 'INTERNALDATE'; } $request = "$key $cmd $message_set (" . implode(' ', $fields) . ")"; if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return false; } $result = array(); do { $line = rtrim($this->readLine(200)); $line = $this->multLine($line); if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) { $id = $m[1]; $flags = null; if ($return_uid) { if (preg_match('/UID ([0-9]+)/', $line, $matches)) { $id = (int) $matches[1]; } else { continue; } } if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) { $flags = explode(' ', strtoupper($matches[1])); if (in_array('\\DELETED', $flags)) { continue; } } if ($mode == 1 && $index_field == 'DATE') { if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) { $value = preg_replace(array('/^"*[a-z]+:/i'), '', $matches[1]); $value = trim($value); $result[$id] = rcube_utils::strtotime($value); } // non-existent/empty Date: header, use INTERNALDATE if (empty($result[$id])) { if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) { $result[$id] = rcube_utils::strtotime($matches[1]); } else { $result[$id] = 0; } } } else if ($mode == 1) { if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) { $value = preg_replace(array('/^"*[a-z]+:/i', '/\s+$/sm'), array('', ''), $matches[2]); $result[$id] = trim($value); } else { $result[$id] = ''; } } else if ($mode == 2) { if (preg_match('/' . $index_field . ' ([0-9]+)/', $line, $matches)) { $result[$id] = trim($matches[1]); } else { $result[$id] = 0; } } else if ($mode == 3) { if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) { $flags = explode(' ', $matches[1]); } $result[$id] = in_array("\\".$index_field, (array) $flags) ? 1 : 0; } else if ($mode == 4) { if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) { $result[$id] = rcube_utils::strtotime($matches[1]); } else { $result[$id] = 0; } } } } while (!$this->startsWith($line, $key, true, true)); return $result; } /** * Returns message sequence identifier * * @param string $mailbox Mailbox name * @param int $uid Message unique identifier (UID) * * @return int Message sequence identifier */ public function UID2ID($mailbox, $uid) { if ($uid > 0) { $index = $this->search($mailbox, "UID $uid"); if ($index->count() == 1) { $arr = $index->get(); return (int) $arr[0]; } } } /** * Returns message unique identifier (UID) * * @param string $mailbox Mailbox name * @param int $uid Message sequence identifier * * @return int Message unique identifier */ public function ID2UID($mailbox, $id) { if (empty($id) || $id < 0) { return null; } if (!$this->select($mailbox)) { return null; } if ($uid = $this->data['UID-MAP'][$id]) { return $uid; } if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) { return null; } $index = $this->search($mailbox, $id, true); if ($index->count() == 1) { $arr = $index->get(); return $this->data['UID-MAP'][$id] = (int) $arr[0]; } } /** * Sets flag of the message(s) * * @param string $mailbox Mailbox name * @param string|array $messages Message UID(s) * @param string $flag Flag name * * @return bool True on success, False on failure */ public function flag($mailbox, $messages, $flag) { return $this->modFlag($mailbox, $messages, $flag, '+'); } /** * Unsets flag of the message(s) * * @param string $mailbox Mailbox name * @param string|array $messages Message UID(s) * @param string $flag Flag name * * @return bool True on success, False on failure */ public function unflag($mailbox, $messages, $flag) { return $this->modFlag($mailbox, $messages, $flag, '-'); } /** * Changes flag of the message(s) * * @param string $mailbox Mailbox name * @param string|array $messages Message UID(s) * @param string $flag Flag name * @param string $mod Modifier [+|-]. Default: "+". * * @return bool True on success, False on failure */ protected function modFlag($mailbox, $messages, $flag, $mod = '+') { if (!$flag) { return false; } if (!$this->select($mailbox)) { return false; } if (!$this->data['READ-WRITE']) { $this->setError(self::ERROR_READONLY, "Mailbox is read-only"); return false; } if ($this->flags[strtoupper($flag)]) { $flag = $this->flags[strtoupper($flag)]; } // if PERMANENTFLAGS is not specified all flags are allowed if (!empty($this->data['PERMANENTFLAGS']) && !in_array($flag, (array) $this->data['PERMANENTFLAGS']) && !in_array('\\*', (array) $this->data['PERMANENTFLAGS']) ) { return false; } // Clear internal status cache if ($flag == 'SEEN') { unset($this->data['STATUS:'.$mailbox]['UNSEEN']); } if ($mod != '+' && $mod != '-') { $mod = '+'; } $result = $this->execute('UID STORE', array( $this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Copies message(s) from one folder to another * * @param string|array $messages Message UID(s) * @param string $from Mailbox name * @param string $to Destination mailbox name * * @return bool True on success, False on failure */ public function copy($messages, $from, $to) { // Clear last COPYUID data unset($this->data['COPYUID']); if (!$this->select($from)) { return false; } // Clear internal status cache unset($this->data['STATUS:'.$to]); $result = $this->execute('UID COPY', array( $this->compressMessageSet($messages), $this->escape($to)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } /** * Moves message(s) from one folder to another. * * @param string|array $messages Message UID(s) * @param string $from Mailbox name * @param string $to Destination mailbox name * * @return bool True on success, False on failure */ public function move($messages, $from, $to) { if (!$this->select($from)) { return false; } if (!$this->data['READ-WRITE']) { $this->setError(self::ERROR_READONLY, "Mailbox is read-only"); return false; } // use MOVE command (RFC 6851) if ($this->hasCapability('MOVE')) { // Clear last COPYUID data unset($this->data['COPYUID']); // Clear internal status cache unset($this->data['STATUS:'.$to]); $this->clear_status_cache($from); $result = $this->execute('UID MOVE', array( $this->compressMessageSet($messages), $this->escape($to)), self::COMMAND_NORESPONSE); return $result == self::ERROR_OK; } // use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE $result = $this->copy($messages, $from, $to); if ($result) { // Clear internal status cache unset($this->data['STATUS:'.$from]); $result = $this->flag($from, $messages, 'DELETED'); if ($messages == '*') { // CLOSE+SELECT should be faster than EXPUNGE $this->close(); } else { $this->expunge($from, $messages); } } return $result; } /** * FETCH command (RFC3501) * * @param string $mailbox Mailbox name * @param mixed $message_set Message(s) sequence identifier(s) or UID(s) * @param bool $is_uid True if $message_set contains UIDs * @param array $query_items FETCH command data items * @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query * @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query * * @return array List of rcube_message_header elements, False on error * @since 0.6 */ public function fetch($mailbox, $message_set, $is_uid = false, $query_items = array(), $mod_seq = null, $vanished = false) { if (!$this->select($mailbox)) { return false; } $message_set = $this->compressMessageSet($message_set); $result = array(); $key = $this->nextTag(); $cmd = ($is_uid ? 'UID ' : '') . 'FETCH'; $request = "$key $cmd $message_set (" . implode(' ', $query_items) . ")"; if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) { $request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")"; } if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return false; } do { $line = $this->readLine(4096); if (!$line) { break; } // Sample reply line: // * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen) // INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...) // BODY[HEADER.FIELDS ... if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) { $id = intval($m[1]); $result[$id] = new rcube_message_header; $result[$id]->id = $id; $result[$id]->subject = ''; $result[$id]->messageID = 'mid:' . $id; $headers = null; $lines = array(); $line = substr($line, strlen($m[0]) + 2); $ln = 0; // get complete entry while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) { $bytes = $m[1]; $out = ''; while (strlen($out) < $bytes) { $out = $this->readBytes($bytes); if ($out === null) { break; } $line .= $out; } $str = $this->readLine(4096); if ($str === false) { break; } $line .= $str; } // Tokenize response and assign to object properties while (list($name, $value) = $this->tokenizeResponse($line, 2)) { if ($name == 'UID') { $result[$id]->uid = intval($value); } else if ($name == 'RFC822.SIZE') { $result[$id]->size = intval($value); } else if ($name == 'RFC822.TEXT') { $result[$id]->body = $value; } else if ($name == 'INTERNALDATE') { $result[$id]->internaldate = $value; $result[$id]->date = $value; $result[$id]->timestamp = rcube_utils::strtotime($value); } else if ($name == 'FLAGS') { if (!empty($value)) { foreach ((array)$value as $flag) { $flag = str_replace(array('$', "\\"), '', $flag); $flag = strtoupper($flag); $result[$id]->flags[$flag] = true; } } } else if ($name == 'MODSEQ') { $result[$id]->modseq = $value[0]; } else if ($name == 'ENVELOPE') { $result[$id]->envelope = $value; } else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) { if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) { $value = array($value); } $result[$id]->bodystructure = $value; } else if ($name == 'RFC822') { $result[$id]->body = $value; } else if (stripos($name, 'BODY[') === 0) { $name = str_replace(']', '', substr($name, 5)); if ($name == 'HEADER.FIELDS') { // skip ']' after headers list $this->tokenizeResponse($line, 1); $headers = $this->tokenizeResponse($line, 1); } else if (strlen($name)) { $result[$id]->bodypart[$name] = $value; } else { $result[$id]->body = $value; } } } // create array with header field:data if (!empty($headers)) { $headers = explode("\n", trim($headers)); foreach ($headers as $resln) { if (ord($resln[0]) <= 32) { $lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($resln); } else { $lines[++$ln] = trim($resln); } } foreach ($lines as $str) { list($field, $string) = explode(':', $str, 2); $field = strtolower($field); $string = preg_replace('/\n[\t\s]*/', ' ', trim($string)); switch ($field) { case 'date'; $string = substr($string, 0, 128); $result[$id]->date = $string; $result[$id]->timestamp = rcube_utils::strtotime($string); break; case 'to': $result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string); break; case 'from': case 'subject': $string = substr($string, 0, 2048); case 'cc': case 'bcc': case 'references': $result[$id]->{$field} = $string; break; case 'reply-to': $result[$id]->replyto = $string; break; case 'content-transfer-encoding': $result[$id]->encoding = substr($string, 0, 32); break; case 'content-type': $ctype_parts = preg_split('/[; ]+/', $string); $result[$id]->ctype = strtolower(array_shift($ctype_parts)); if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) { $result[$id]->charset = $regs[1]; } break; case 'in-reply-to': $result[$id]->in_reply_to = str_replace(array("\n", '<', '>'), '', $string); break; case 'return-receipt-to': case 'disposition-notification-to': case 'x-confirm-reading-to': $result[$id]->mdn_to = substr($string, 0, 2048); break; case 'message-id': $result[$id]->messageID = substr($string, 0, 2048); break; case 'x-priority': if (preg_match('/^(\d+)/', $string, $matches)) { $result[$id]->priority = intval($matches[1]); } break; default: if (strlen($field) < 3) { break; } if ($result[$id]->others[$field]) { $string = array_merge((array)$result[$id]->others[$field], (array)$string); } $result[$id]->others[$field] = $string; } } } } // VANISHED response (QRESYNC RFC5162) // Sample: * VANISHED (EARLIER) 300:310,405,411 else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) { $line = substr($line, strlen($match[0])); $v_data = $this->tokenizeResponse($line, 1); $this->data['VANISHED'] = $v_data; } } while (!$this->startsWith($line, $key, true)); return $result; } /** * Returns message(s) data (flags, headers, etc.) * * @param string $mailbox Mailbox name * @param mixed $message_set Message(s) sequence identifier(s) or UID(s) * @param bool $is_uid True if $message_set contains UIDs * @param bool $bodystr Enable to add BODYSTRUCTURE data to the result * @param array $add_headers List of additional headers * * @return bool|array List of rcube_message_header elements, False on error */ public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = array()) { $query_items = array('UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE'); $headers = array('DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO', 'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY'); if (!empty($add_headers)) { $add_headers = array_map('strtoupper', $add_headers); $headers = array_unique(array_merge($headers, $add_headers)); } if ($bodystr) { $query_items[] = 'BODYSTRUCTURE'; } $query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]'; return $this->fetch($mailbox, $message_set, $is_uid, $query_items); } /** * Returns message data (flags, headers, etc.) * * @param string $mailbox Mailbox name * @param int $id Message sequence identifier or UID * @param bool $is_uid True if $id is an UID * @param bool $bodystr Enable to add BODYSTRUCTURE data to the result * @param array $add_headers List of additional headers * * @return bool|rcube_message_header Message data, False on error */ public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = array()) { $a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers); if (is_array($a)) { return array_shift($a); } return false; } /** * Sort messages by specified header field * * @param array $messages Array of rcube_message_header objects * @param string $field Name of the property to sort by * @param string $flag Sorting order (ASC|DESC) * * @return array Sorted input array */ public static function sortHeaders($messages, $field, $flag) { $field = empty($field) ? 'uid' : strtolower($field); $order = empty($flag) ? 'ASC' : strtoupper($flag); $index = array(); reset($messages); // Create an index foreach ($messages as $key => $headers) { switch ($field) { case 'arrival': $field = 'internaldate'; // no-break case 'date': case 'internaldate': case 'timestamp': $value = rcube_utils::strtotime($headers->$field); if (!$value && $field != 'timestamp') { $value = $headers->timestamp; } break; default: // @TODO: decode header value, convert to UTF-8 $value = $headers->$field; if (is_string($value)) { $value = str_replace('"', '', $value); if ($field == 'subject') { $value = preg_replace('/^(Re:\s*|Fwd:\s*|Fw:\s*)+/i', '', $value); } } } $index[$key] = $value; } $sort_order = $flag == 'ASC' ? SORT_ASC : SORT_DESC; $sort_flags = SORT_STRING | SORT_FLAG_CASE; if (in_array($field, array('arrival', 'date', 'internaldate', 'timestamp'))) { $sort_flags = SORT_NUMERIC; } array_multisort($index, $sort_order, $sort_flags, $messages); return $messages; } /** * Fetch MIME headers of specified message parts * * @param string $mailbox Mailbox name * @param int $uid Message UID * @param array $parts Message part identifiers * @param bool $mime Use MIME instad of HEADER * * @return array|bool Array containing headers string for each specified body * False on failure. */ public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true) { if (!$this->select($mailbox)) { return false; } $result = false; $parts = (array) $parts; $key = $this->nextTag(); $peeks = array(); $type = $mime ? 'MIME' : 'HEADER'; // format request foreach ($parts as $part) { $peeks[] = "BODY.PEEK[$part.$type]"; } $request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')'; // send request if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command"); return false; } do { $line = $this->readLine(1024); if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) { $line = ltrim(substr($line, strlen($m[0]))); while (preg_match('/^BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) { $line = substr($line, strlen($matches[0])); $result[$matches[1]] = trim($this->multLine($line)); $line = $this->readLine(1024); } } } while (!$this->startsWith($line, $key, true)); return $result; } /** * Fetches message part header */ public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null) { $part = empty($part) ? 'HEADER' : $part.'.MIME'; return $this->handlePartBody($mailbox, $id, $is_uid, $part); } /** * Fetches body of the specified message part */ public function handlePartBody($mailbox, $id, $is_uid=false, $part='', $encoding=null, $print=null, $file=null, $formatted=false, $max_bytes=0) { if (!$this->select($mailbox)) { return false; } $binary = true; do { if (!$initiated) { switch ($encoding) { case 'base64': $mode = 1; break; case 'quoted-printable': $mode = 2; break; case 'x-uuencode': case 'x-uue': case 'uue': case 'uuencode': $mode = 3; break; default: $mode = 0; } // Use BINARY extension when possible (and safe) $binary = $binary && $mode && preg_match('/^[0-9.]+$/', $part) && $this->hasCapability('BINARY'); $fetch_mode = $binary ? 'BINARY' : 'BODY'; $partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : ''; // format request $key = $this->nextTag(); $cmd = ($is_uid ? 'UID ' : '') . 'FETCH'; $request = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)"; $result = false; $found = false; $initiated = true; // send request if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return false; } if ($binary) { // WARNING: Use $formatted argument with care, this may break binary data stream $mode = -1; } } $line = trim($this->readLine(1024)); if (!$line) { break; } // handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) { $binary = $initiated = false; continue; } // skip irrelevant untagged responses (we have a result already) if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) { continue; } $line = $m[2]; // handle one line response if ($line[0] == '(' && substr($line, -1) == ')') { // tokenize content inside brackets // the content can be e.g.: (UID 9844 BODY[2.4] NIL) $tokens = $this->tokenizeResponse(preg_replace('/(^\(|\)$)/', '', $line)); for ($i=0; $i 0) { $line = $this->readLine(8192); if ($line === null) { break; } $len = strlen($line); if ($len > $bytes) { $line = substr($line, 0, $bytes); $len = strlen($line); } $bytes -= $len; // BASE64 if ($mode == 1) { $line = preg_replace('|[^a-zA-Z0-9+=/]|', '', $line); // create chunks with proper length for base64 decoding $line = $prev.$line; $length = strlen($line); if ($length % 4) { $length = floor($length / 4) * 4; $prev = substr($line, $length); $line = substr($line, 0, $length); } else { $prev = ''; } $line = base64_decode($line); } // QUOTED-PRINTABLE else if ($mode == 2) { $line = rtrim($line, "\t\r\0\x0B"); $line = quoted_printable_decode($line); } // UUENCODE else if ($mode == 3) { $line = rtrim($line, "\t\r\n\0\x0B"); if ($line == 'end' || preg_match('/^begin\s+[0-7]+\s+.+$/', $line)) { continue; } $line = convert_uudecode($line); } // default else if ($formatted) { $line = rtrim($line, "\t\r\n\0\x0B") . "\n"; } if ($file) { if (fwrite($file, $line) === false) { break; } } else if ($print) { echo $line; } else { $result .= $line; } } } } while (!$this->startsWith($line, $key, true) || !$initiated); if ($result !== false) { if ($file) { return fwrite($file, $result); } else if ($print) { echo $result; return true; } return $result; } return false; } /** * Handler for IMAP APPEND command * * @param string $mailbox Mailbox name * @param string|array $message The message source string or array (of strings and file pointers) * @param array $flags Message flags * @param string $date Message internal date * @param bool $binary Enable BINARY append (RFC3516) * * @return string|bool On success APPENDUID response (if available) or True, False on failure */ public function append($mailbox, &$message, $flags = array(), $date = null, $binary = false) { unset($this->data['APPENDUID']); if ($mailbox === null || $mailbox === '') { return false; } $binary = $binary && $this->getCapability('BINARY'); $literal_plus = !$binary && $this->prefs['literal+']; $len = 0; $msg = is_array($message) ? $message : array(&$message); $chunk_size = 512000; for ($i=0, $cnt=count($msg); $i<$cnt; $i++) { if (is_resource($msg[$i])) { $stat = fstat($msg[$i]); if ($stat === false) { return false; } $len += $stat['size']; } else { if (!$binary) { $msg[$i] = str_replace("\r", '', $msg[$i]); $msg[$i] = str_replace("\n", "\r\n", $msg[$i]); } $len += strlen($msg[$i]); } } if (!$len) { return false; } // build APPEND command $key = $this->nextTag(); $request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')'; if (!empty($date)) { $request .= ' ' . $this->escape($date); } $request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}'; // send APPEND command if (!$this->putLine($request)) { $this->setError(self::ERROR_COMMAND, "Failed to send APPEND command"); return false; } // Do not wait when LITERAL+ is supported if (!$literal_plus) { $line = $this->readReply(); if ($line[0] != '+') { $this->parseResult($line, 'APPEND: '); return false; } } foreach ($msg as $msg_part) { // file pointer if (is_resource($msg_part)) { rewind($msg_part); while (!feof($msg_part) && $this->fp) { $buffer = fread($msg_part, $chunk_size); $this->putLine($buffer, false); } fclose($msg_part); } // string else { $size = strlen($msg_part); // Break up the data by sending one chunk (up to 512k) at a time. // This approach reduces our peak memory usage for ($offset = 0; $offset < $size; $offset += $chunk_size) { $chunk = substr($msg_part, $offset, $chunk_size); if (!$this->putLine($chunk, false)) { return false; } } } } if (!$this->putLine('')) { // \r\n return false; } do { $line = $this->readLine(); } while (!$this->startsWith($line, $key, true, true)); // Clear internal status cache unset($this->data['STATUS:'.$mailbox]); if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) { return false; } if (!empty($this->data['APPENDUID'])) { return $this->data['APPENDUID']; } return true; } /** * Handler for IMAP APPEND command. * * @param string $mailbox Mailbox name * @param string $path Path to the file with message body * @param string $headers Message headers * @param array $flags Message flags * @param string $date Message internal date * @param bool $binary Enable BINARY append (RFC3516) * * @return string|bool On success APPENDUID response (if available) or True, False on failure */ public function appendFromFile($mailbox, $path, $headers=null, $flags = array(), $date = null, $binary = false) { // open message file if (file_exists(realpath($path))) { $fp = fopen($path, 'r'); } if (!$fp) { $this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading"); return false; } $message = array(); if ($headers) { $message[] = trim($headers, "\r\n") . "\r\n\r\n"; } $message[] = $fp; return $this->append($mailbox, $message, $flags, $date, $binary); } /** * Returns QUOTA information * * @param string $mailbox Mailbox name * * @return array Quota information */ public function getQuota($mailbox = null) { if ($mailbox === null || $mailbox === '') { $mailbox = 'INBOX'; } // a0001 GETQUOTAROOT INBOX // * QUOTAROOT INBOX user/sample // * QUOTA user/sample (STORAGE 654 9765) // a0001 OK Completed list($code, $response) = $this->execute('GETQUOTAROOT', array($this->escape($mailbox)), 0, '/^\* QUOTA /i'); $result = false; $min_free = PHP_INT_MAX; $all = array(); if ($code == self::ERROR_OK) { foreach (explode("\n", $response) as $line) { list(, , $quota_root) = $this->tokenizeResponse($line, 3); $quotas = $this->tokenizeResponse($line, 1); if (empty($quotas)) { continue; } foreach (array_chunk($quotas, 3) as $quota) { list($type, $used, $total) = $quota; $type = strtolower($type); if ($type && $total) { $all[$quota_root][$type]['used'] = intval($used); $all[$quota_root][$type]['total'] = intval($total); } } if (empty($all[$quota_root]['storage'])) { continue; } $used = $all[$quota_root]['storage']['used']; $total = $all[$quota_root]['storage']['total']; $free = $total - $used; // calculate lowest available space from all storage quotas if ($free < $min_free) { $min_free = $free; $result['used'] = $used; $result['total'] = $total; $result['percent'] = min(100, round(($used/max(1,$total))*100)); $result['free'] = 100 - $result['percent']; } } } if (!empty($result)) { $result['all'] = $all; } return $result; } /** * Send the SETQUOTA command (RFC9208) * * @param string $root Quota root * @param array $quota Quota limits e.g. ['storage' => 1024000'] * * @return boolean True on success, False on failure */ public function setQuota($root, $quota) { $fn = function ($key, $value) { return strtoupper($key) . ' ' . $value; }; $quota = implode(' ', array_map($fn, array_keys($quota), $quota)); $result = $this->execute('SETQUOTA', [$this->escape($root), "({$quota})"], self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the SETACL command (RFC4314) * * @param string $mailbox Mailbox name * @param string $user User name * @param mixed $acl ACL string or array * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function setACL($mailbox, $user, $acl) { if (is_array($acl)) { $acl = implode('', $acl); } $result = $this->execute('SETACL', array( $this->escape($mailbox), $this->escape($user), strtolower($acl)), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the DELETEACL command (RFC4314) * * @param string $mailbox Mailbox name * @param string $user User name * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function deleteACL($mailbox, $user) { $result = $this->execute('DELETEACL', array( $this->escape($mailbox), $this->escape($user)), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the GETACL command (RFC4314) * * @param string $mailbox Mailbox name * * @return array User-rights array on success, NULL on error * @since 0.5-beta */ public function getACL($mailbox) { list($code, $response) = $this->execute('GETACL', array($this->escape($mailbox)), 0, '/^\* ACL /i'); if ($code == self::ERROR_OK && $response) { // Parse server response (remove "* ACL ") $response = substr($response, 6); $ret = $this->tokenizeResponse($response); $mbox = array_shift($ret); $size = count($ret); // Create user-rights hash array // @TODO: consider implementing fixACL() method according to RFC4314.2.1.1 // so we could return only standard rights defined in RFC4314, // excluding 'c' and 'd' defined in RFC2086. if ($size % 2 == 0) { for ($i=0; $i<$size; $i++) { $ret[$ret[$i]] = str_split($ret[++$i]); unset($ret[$i-1]); unset($ret[$i]); } return $ret; } $this->setError(self::ERROR_COMMAND, "Incomplete ACL response"); } } /** * Send the LISTRIGHTS command (RFC4314) * * @param string $mailbox Mailbox name * @param string $user User name * * @return array List of user rights * @since 0.5-beta */ public function listRights($mailbox, $user) { list($code, $response) = $this->execute('LISTRIGHTS', array($this->escape($mailbox), $this->escape($user)), 0, '/^\* LISTRIGHTS /i'); if ($code == self::ERROR_OK && $response) { // Parse server response (remove "* LISTRIGHTS ") $response = substr($response, 13); $ret_mbox = $this->tokenizeResponse($response, 1); $ret_user = $this->tokenizeResponse($response, 1); $granted = $this->tokenizeResponse($response, 1); $optional = trim($response); return array( 'granted' => str_split($granted), 'optional' => explode(' ', $optional), ); } } /** * Send the MYRIGHTS command (RFC4314) * * @param string $mailbox Mailbox name * * @return array MYRIGHTS response on success, NULL on error * @since 0.5-beta */ public function myRights($mailbox) { list($code, $response) = $this->execute('MYRIGHTS', array($this->escape($mailbox)), 0, '/^\* MYRIGHTS /i'); if ($code == self::ERROR_OK && $response) { // Parse server response (remove "* MYRIGHTS ") $response = substr($response, 11); $ret_mbox = $this->tokenizeResponse($response, 1); $rights = $this->tokenizeResponse($response, 1); return str_split($rights); } } /** * Send the SETMETADATA command (RFC5464) * * @param string $mailbox Mailbox name * @param array $entries Entry-value array (use NULL value as NIL) * * @return boolean True on success, False on failure * @since 0.5-beta */ public function setMetadata($mailbox, $entries) { if (!is_array($entries) || empty($entries)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command"); return false; } foreach ($entries as $name => $value) { $entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true); } $entries = implode(' ', $entries); $result = $this->execute('SETMETADATA', array( $this->escape($mailbox), '(' . $entries . ')'), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the SETMETADATA command with NIL values (RFC5464) * * @param string $mailbox Mailbox name * @param array $entries Entry names array * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function deleteMetadata($mailbox, $entries) { if (!is_array($entries) && !empty($entries)) { $entries = explode(' ', $entries); } if (empty($entries)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command"); return false; } foreach ($entries as $entry) { $data[$entry] = null; } return $this->setMetadata($mailbox, $data); } /** * Send the GETMETADATA command (RFC5464) * * @param string $mailbox Mailbox name * @param array $entries Entries * @param array $options Command options (with MAXSIZE and DEPTH keys) * * @return array GETMETADATA result on success, NULL on error * * @since 0.5-beta */ public function getMetadata($mailbox, $entries, $options=array()) { if (!is_array($entries)) { $entries = array($entries); } // create entries string foreach ($entries as $idx => $name) { $entries[$idx] = $this->escape($name); } $optlist = ''; $entlist = '(' . implode(' ', $entries) . ')'; // create options string if (is_array($options)) { $options = array_change_key_case($options, CASE_UPPER); $opts = array(); if (!empty($options['MAXSIZE'])) { $opts[] = 'MAXSIZE '.intval($options['MAXSIZE']); } if (!empty($options['DEPTH'])) { $opts[] = 'DEPTH '.intval($options['DEPTH']); } if ($opts) { $optlist = '(' . implode(' ', $opts) . ')'; } } $optlist .= ($optlist ? ' ' : '') . $entlist; list($code, $response) = $this->execute('GETMETADATA', array( $this->escape($mailbox), $optlist)); if ($code == self::ERROR_OK) { $result = array(); $data = $this->tokenizeResponse($response); // The METADATA response can contain multiple entries in a single // response or multiple responses for each entry or group of entries for ($i = 0, $size = count($data); $i < $size; $i++) { if ($data[$i] === '*' && $data[++$i] === 'METADATA' && is_string($mbox = $data[++$i]) && is_array($data[++$i]) ) { for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) { if ($data[$i][$x+1] !== null) { $result[$mbox][$data[$i][$x]] = $data[$i][$x+1]; } } } } return $result; } } /** * Send the SETANNOTATION command (draft-daboo-imap-annotatemore) * * @param string $mailbox Mailbox name * @param array $data Data array where each item is an array with * three elements: entry name, attribute name, value * * @return boolean True on success, False on failure * @since 0.5-beta */ public function setAnnotation($mailbox, $data) { if (!is_array($data) || empty($data)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command"); return false; } foreach ($data as $entry) { // ANNOTATEMORE drafts before version 08 require quoted parameters $entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true), $this->escape($entry[1], true), $this->escape($entry[2], true)); } $entries = implode(' ', $entries); $result = $this->execute('SETANNOTATION', array( $this->escape($mailbox), $entries), self::COMMAND_NORESPONSE); return ($result == self::ERROR_OK); } /** * Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore) * * @param string $mailbox Mailbox name * @param array $data Data array where each item is an array with * two elements: entry name and attribute name * * @return boolean True on success, False on failure * * @since 0.5-beta */ public function deleteAnnotation($mailbox, $data) { if (!is_array($data) || empty($data)) { $this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command"); return false; } return $this->setAnnotation($mailbox, $data); } /** * Send the GETANNOTATION command (draft-daboo-imap-annotatemore) * * @param string $mailbox Mailbox name * @param array $entries Entries names * @param array $attribs Attribs names * * @return array Annotations result on success, NULL on error * * @since 0.5-beta */ public function getAnnotation($mailbox, $entries, $attribs) { if (!is_array($entries)) { $entries = array($entries); } // create entries string // ANNOTATEMORE drafts before version 08 require quoted parameters foreach ($entries as $idx => $name) { $entries[$idx] = $this->escape($name, true); } $entries = '(' . implode(' ', $entries) . ')'; if (!is_array($attribs)) { $attribs = array($attribs); } // create attributes string foreach ($attribs as $idx => $name) { $attribs[$idx] = $this->escape($name, true); } $attribs = '(' . implode(' ', $attribs) . ')'; list($code, $response) = $this->execute('GETANNOTATION', array( $this->escape($mailbox), $entries, $attribs)); if ($code == self::ERROR_OK) { $result = array(); $data = $this->tokenizeResponse($response); // Here we returns only data compatible with METADATA result format if (!empty($data) && ($size = count($data))) { for ($i=0; $i<$size; $i++) { $entry = $data[$i]; if (isset($mbox) && is_array($entry)) { $attribs = $entry; $entry = $last_entry; } else if ($entry == '*') { if ($data[$i+1] == 'ANNOTATION') { $mbox = $data[$i+2]; unset($data[$i]); // "*" unset($data[++$i]); // "ANNOTATION" unset($data[++$i]); // Mailbox } // get rid of other untagged responses else { unset($mbox); unset($data[$i]); } continue; } else if (isset($mbox)) { $attribs = $data[++$i]; } else { unset($data[$i]); continue; } if (!empty($attribs)) { for ($x=0, $len=count($attribs); $x<$len;) { $attr = $attribs[$x++]; $value = $attribs[$x++]; if ($attr == 'value.priv' && $value !== null) { $result[$mbox]['/private' . $entry] = $value; } else if ($attr == 'value.shared' && $value !== null) { $result[$mbox]['/shared' . $entry] = $value; } } } $last_entry = $entry; unset($data[$i]); } } return $result; } } /** * Returns BODYSTRUCTURE for the specified message. * * @param string $mailbox Folder name * @param int $id Message sequence number or UID * @param bool $is_uid True if $id is an UID * * @return array/bool Body structure array or False on error. * @since 0.6 */ public function getStructure($mailbox, $id, $is_uid = false) { $result = $this->fetch($mailbox, $id, $is_uid, array('BODYSTRUCTURE')); if (is_array($result)) { $result = array_shift($result); return $result->bodystructure; } return false; } /** * Returns data of a message part according to specified structure. * * @param array $structure Message structure (getStructure() result) * @param string $part Message part identifier * * @return array Part data as hash array (type, encoding, charset, size) */ public static function getStructurePartData($structure, $part) { $part_a = self::getStructurePartArray($structure, $part); $data = array(); if (empty($part_a)) { return $data; } // content-type if (is_array($part_a[0])) { $data['type'] = 'multipart'; } else { $data['type'] = strtolower($part_a[0]); $data['subtype'] = strtolower($part_a[1]); $data['encoding'] = strtolower($part_a[5]); // charset if (is_array($part_a[2])) { foreach ($part_a[2] as $key => $val) { if (strcasecmp($val, 'charset') == 0) { $data['charset'] = $part_a[2][$key+1]; break; } } } } // size $data['size'] = intval($part_a[6]); return $data; } public static function getStructurePartArray($a, $part) { if (!is_array($a)) { return false; } if (empty($part)) { return $a; } $ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : ''; if (strcasecmp($ctype, 'message/rfc822') == 0) { $a = $a[8]; } if (strpos($part, '.') > 0) { $orig_part = $part; $pos = strpos($part, '.'); $rest = substr($orig_part, $pos+1); $part = substr($orig_part, 0, $pos); return self::getStructurePartArray($a[$part-1], $rest); } else if ($part > 0) { return (is_array($a[$part-1])) ? $a[$part-1] : $a; } } /** * Creates next command identifier (tag) * * @return string Command identifier * @since 0.5-beta */ public function nextTag() { $this->cmd_num++; $this->cmd_tag = sprintf('A%04d', $this->cmd_num); return $this->cmd_tag; } /** * Sends IMAP command and parses result * * @param string $command IMAP command * @param array $arguments Command arguments * @param int $options Execution options * @param string $filter Line filter (regexp) * * @return mixed Response code or list of response code and data * @since 0.5-beta */ public function execute($command, $arguments = array(), $options = 0, $filter = null) { $tag = $this->nextTag(); $query = $tag . ' ' . $command; $noresp = ($options & self::COMMAND_NORESPONSE); $response = $noresp ? null : ''; if (!empty($arguments)) { foreach ($arguments as $arg) { $query .= ' ' . self::r_implode($arg); } } // Send command if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) { preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches); $cmd = $matches[1] ?: 'UNKNOWN'; $this->setError(self::ERROR_COMMAND, "Failed to send $cmd command"); return $noresp ? self::ERROR_COMMAND : array(self::ERROR_COMMAND, ''); } // Parse response do { $line = $this->readLine(4096); if ($response !== null) { // TODO: Better string literals handling with filter if (!$filter || preg_match($filter, $line)) { $response .= $line; } } // parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851) if ($line && $command == 'UID MOVE') { if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) { $this->data['COPYUID'] = array($m[1], $m[2]); } } } while (!$this->startsWith($line, $tag . ' ', true, true)); $code = $this->parseResult($line, $command . ': '); // Remove last line from response if ($response) { if (!$filter) { $line_len = min(strlen($response), strlen($line)); $response = substr($response, 0, -$line_len); } $response = rtrim($response, "\r\n"); } // optional CAPABILITY response if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches) ) { $this->parseCapability($matches[1], true); } // return last line only (without command tag, result and response code) if ($line && ($options & self::COMMAND_LASTLINE)) { $response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line)); } return $noresp ? $code : array($code, $response); } /** * Splits IMAP response into string tokens * * @param string &$str The IMAP's server response * @param int $num Number of tokens to return * * @return mixed Tokens array or string if $num=1 * @since 0.5-beta */ public static function tokenizeResponse(&$str, $num=0) { $result = array(); while (!$num || count($result) < $num) { // remove spaces from the beginning of the string $str = ltrim($str); // empty string if ($str === '' || $str === null) { break; } switch ($str[0]) { // String literal case '{': if (($epos = strpos($str, "}\r\n", 1)) == false) { // error } if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) { // error } $result[] = $bytes ? substr($str, $epos + 3, $bytes) : ''; $str = substr($str, $epos + 3 + $bytes); break; // Quoted string case '"': $len = strlen($str); for ($pos=1; $pos<$len; $pos++) { if ($str[$pos] == '"') { break; } if ($str[$pos] == "\\") { if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") { $pos++; } } } // we need to strip slashes for a quoted string $result[] = stripslashes(substr($str, 1, $pos - 1)); $str = substr($str, $pos + 1); break; // Parenthesized list case '(': $str = substr($str, 1); $result[] = self::tokenizeResponse($str); break; case ')': $str = substr($str, 1); return $result; // String atom, number, astring, NIL, *, % default: // excluded chars: SP, CTL, ), DEL // we do not exclude [ and ] (#1489223) if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) { $result[] = $m[1] == 'NIL' ? null : $m[1]; $str = substr($str, strlen($m[1])); } break; } } return $num == 1 ? $result[0] : $result; } /** * Joins IMAP command line elements (recursively) */ protected static function r_implode($element) { $string = ''; if (is_array($element)) { reset($element); foreach ($element as $value) { $string .= ' ' . self::r_implode($value); } } else { return $element; } return '(' . trim($string) . ')'; } /** * Converts message identifiers array into sequence-set syntax * * @param array $messages Message identifiers * @param bool $force Forces compression of any size * * @return string Compressed sequence-set */ public static function compressMessageSet($messages, $force=false) { // given a comma delimited list of independent mid's, // compresses by grouping sequences together if (!is_array($messages)) { // if less than 255 bytes long, let's not bother if (!$force && strlen($messages) < 255) { return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages; } // see if it's already been compressed if (strpos($messages, ':') !== false) { return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages; } // separate, then sort $messages = explode(',', $messages); } sort($messages); $result = array(); $start = $prev = $messages[0]; foreach ($messages as $id) { $incr = $id - $prev; if ($incr > 1) { // found a gap if ($start == $prev) { $result[] = $prev; // push single id } else { $result[] = $start . ':' . $prev; // push sequence as start_id:end_id } $start = $id; // start of new sequence } $prev = $id; } // handle the last sequence/id if ($start == $prev) { $result[] = $prev; } else { $result[] = $start.':'.$prev; } // return as comma separated string $result = implode(',', $result); return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result; } /** * Converts message sequence-set into array * * @param string $messages Message identifiers * * @return array List of message identifiers */ public static function uncompressMessageSet($messages) { if (empty($messages)) { return array(); } $result = array(); $messages = explode(',', $messages); foreach ($messages as $idx => $part) { $items = explode(':', $part); $max = max($items[0], $items[1]); for ($x=$items[0]; $x<=$max; $x++) { $result[] = (int)$x; } unset($messages[$idx]); } return $result; } /** * Clear internal status cache */ protected function clear_status_cache($mailbox) { unset($this->data['STATUS:' . $mailbox]); $keys = array('EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP'); foreach ($keys as $key) { unset($this->data[$key]); } } /** * Clear internal cache of the current mailbox */ protected function clear_mailbox_cache() { $this->clear_status_cache($this->selected); $keys = array('UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ', 'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE'); foreach ($keys as $key) { unset($this->data[$key]); } } /** * Converts flags array into string for inclusion in IMAP command * * @param array $flags Flags (see self::flags) * * @return string Space-separated list of flags */ protected function flagsToStr($flags) { foreach ((array)$flags as $idx => $flag) { if ($flag = $this->flags[strtoupper($flag)]) { $flags[$idx] = $flag; } } return implode(' ', (array)$flags); } /** * CAPABILITY response parser */ protected function parseCapability($str, $trusted=false) { $str = preg_replace('/^\* CAPABILITY /i', '', $str); $this->capability = explode(' ', strtoupper($str)); if (!empty($this->prefs['disabled_caps'])) { $this->capability = array_diff($this->capability, $this->prefs['disabled_caps']); } if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) { $this->prefs['literal+'] = true; } if ($trusted) { $this->capability_readed = true; } } /** * Escapes a string when it contains special characters (RFC3501) * * @param string $string IMAP string * @param boolean $force_quotes Forces string quoting (for atoms) * * @return string String atom, quoted-string or string literal * @todo lists */ public static function escape($string, $force_quotes=false) { if ($string === null) { return 'NIL'; } if ($string === '') { return '""'; } // atom-string (only safe characters) if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) { return $string; } // quoted-string if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) { return '"' . addcslashes($string, '\\"') . '"'; } // literal-string return sprintf("{%d}\r\n%s", strlen($string), $string); } /** * Set the value of the debugging flag. * - * @param boolean $debug New value for the debugging flag. - * @param callback $handler Logging handler function + * @param bool $debug New value for the debugging flag. + * @param ?callable $handler Logging handler function * * @since 0.5-stable */ public function setDebug($debug, $handler = null) { $this->debug = $debug; $this->debug_handler = $handler; } /** * Write the given debug text to the current debug output handler. * * @param string $message Debug message text. * * @since 0.5-stable */ protected function debug($message) { if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) { $diff = $len - self::DEBUG_LINE_LENGTH; $message = substr($message, 0, self::DEBUG_LINE_LENGTH) . "... [truncated $diff bytes]"; } if ($this->resourceid) { $message = sprintf('[%s] %s', $this->resourceid, $message); } if ($this->debug_handler) { call_user_func_array($this->debug_handler, array($this, $message)); } else { echo "DEBUG: $message\n"; } } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 4a6ee260..01dfa085 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,20 +1,20 @@ includes: - ./vendor/larastan/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#' - '#Call to an undefined method Tests\\Browser::#' - level: 4 + level: 5 parallel: processTimeout: 300.0 paths: - app/ - config/ - database/ - resources/ - routes/ - tests/ - resources/ diff --git a/src/tests/Browser/Components/QuotaInput.php b/src/tests/Browser/Components/QuotaInput.php index 9ee63331..16624596 100644 --- a/src/tests/Browser/Components/QuotaInput.php +++ b/src/tests/Browser/Components/QuotaInput.php @@ -1,88 +1,88 @@ selector = trim($selector); } /** * Get the root selector for the component. * * @return string */ public function selector() { return $this->selector; } /** * Assert that the browser page contains the component. * * @param \Laravel\Dusk\Browser $browser * * @return void */ public function assert($browser) { $browser->waitFor($this->selector() . ' input[type=range]'); } /** * Assert input value * * @param \Laravel\Dusk\Browser $browser The browser * @param int $value Value in GB * * @return void */ public function assertQuotaValue($browser, $value) { - $browser->assertValue('@input', $value) + $browser->assertValue('@input', (string) $value) ->assertSeeIn('@label', "$value GB"); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { return [ '@label' => 'label', '@input' => 'input', ]; } /** * Set input value * * @param \Laravel\Dusk\Browser $browser The browser * @param int $value Value in GB * * @return void */ public function setQuotaValue($browser, $value) { // Use keyboard because ->value() does not work here $browser->click('@input')->keys('@input', '{home}'); $num = $value - 5; while ($num > 0) { $browser->keys('@input', '{arrow_right}'); $num--; } $browser->assertSeeIn('@label', "$value GB"); } } diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php index 24011a34..c3da1656 100644 --- a/src/tests/Browser/Meet/RoomSetupTest.php +++ b/src/tests/Browser/Meet/RoomSetupTest.php @@ -1,568 +1,568 @@ resetTestRoom(); } public function tearDown(): void { $this->resetTestRoom(); parent::tearDown(); } /** * Test non-existing room * * @group meet */ public function testRoomNonExistingRoom(): void { $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('unknown')) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login', 'lang']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } // FIXME: Maybe it would be better to just display the usual 404 Not Found error page? $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room does not exist.") ->assertButtonDisabled('@setup-button'); }); } /** * Test the room setup page * * @group meet */ public function testRoomSetup(): void { $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('john')) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login', 'lang']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } // Note: I've found out that if I have another Chrome instance running // that uses media, here the media devices will not be available // TODO: Test enabling/disabling cam/mic in the setup widget $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-title', 'Set up your session') ->assertVisible('@setup-video') ->assertVisible('@setup-form .input-group:nth-child(1) svg') ->assertAttribute('@setup-form .input-group:nth-child(1) .input-group-text', 'title', 'Microphone') ->assertVisible('@setup-mic-select') ->assertVisible('@setup-form .input-group:nth-child(2) svg') ->assertAttribute('@setup-form .input-group:nth-child(2) .input-group-text', 'title', 'Camera') ->assertVisible('@setup-cam-select') ->assertVisible('@setup-form .input-group:nth-child(3) svg') ->assertAttribute('@setup-form .input-group:nth-child(3) .input-group-text', 'title', 'Nickname') ->assertValue('@setup-nickname-input', '') ->assertAttribute('@setup-nickname-input', 'placeholder', 'Your name') ->assertMissing('@setup-password-input') ->assertSeeIn( '@setup-status-message', "The room is closed. Please, wait for the owner to start the session." ) ->assertSeeIn('@setup-button', "I'm the owner"); }); } /** * Test two users in a room (joining/leaving and some basic functionality) * * @group meet * @depends testRoomSetup */ public function testTwoUsersInARoom(): void { $this->browse(function (Browser $browser, Browser $guest) { // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertSeeIn( '@setup-status-message', "The room is closed. Please, wait for the owner to start the session." ) ->assertSeeIn('@setup-button', "I'm the owner"); // In another window join the room as the owner (authenticate) $browser->on(new RoomPage('john')) ->assertSeeIn('@setup-button', "I'm the owner") ->clickWhenEnabled('@setup-button') ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@setup-form') ->assertVisible('@login-form') ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'dashboard', 'logout', 'lang']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'dashboard', 'logout']); }); } $browser->assertMissing('@login-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.") ->assertSeeIn('@setup-button', "JOIN") ->type('@setup-nickname-input', 'john') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('div.meet-video.self', function (Browser $browser) { $browser->waitFor('video') ->assertSeeIn('.meet-nickname', 'john') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertMissing('#header-menu') ->assertSeeIn('@counter', 1); if (!$browser->isPhone()) { $browser->assertMissing('#footer-menu'); } else { $browser->assertVisible('#footer-menu'); } // After the owner "opened the room" guest should be able to join $guest->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') //->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('div.meet-video.self', function (Browser $browser) { $browser->waitFor('video') ->assertVisible('.meet-nickname') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertMissing('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) { $browser->waitFor('video') ->assertSeeIn('.meet-nickname', 'john') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2) ->assertSeeIn('@counter', 2); // Check guest's elements in the owner's window $browser ->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) { $browser->waitFor('video') ->assertVisible('.meet-nickname') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.controls button.link-setup') ->assertVisible('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2); // Test leaving the room // Guest is leaving $guest->click('@menu button.link-logout') ->waitForLocation('/login') ->assertVisible('#header-menu'); // Expect the participant removed from other users windows $browser->waitUntilMissing('@session div.meet-video:not(.self)') - ->assertSeeIn('@counter', 1); + ->assertSeeIn('@counter', '1'); // Join the room as guest again $guest->visit(new RoomPage('john')) ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertMissing('@setup-status-message') ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') //->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session'); // Leave the room as the room owner // TODO: Test leaving the room by closing the browser window, // it should not destroy the session $browser->click('@menu button.link-logout') ->waitForLocation('/dashboard'); // Expect other participants be informed about the end of the session $guest->with(new Dialog('#leave-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Room closed') ->assertSeeIn('@body', "The session has been closed by the room owner.") ->assertSeeIn('@button-cancel', 'Close') ->click('@button-cancel'); }) ->assertMissing('#leave-dialog') ->waitForLocation('/login'); }); } /** * Test two subscribers-only users in a room * * @group meet * @depends testTwoUsersInARoom */ public function testSubscribers(): void { $this->browse(function (Browser $browser, Browser $guest) { // Join the room as the owner $browser->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->type('@setup-nickname-input', 'john') ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) { $browser->assertSeeIn('.meet-nickname', 'john'); }) ->assertElementsCount('@session div.meet-video', 0) ->assertElementsCount('@session video', 0) ->assertElementsCount('@session .meet-subscriber', 1) ->assertToolbar([ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED, 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED, 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'fullscreen' => RoomPage::BUTTON_ENABLED, 'options' => RoomPage::BUTTON_ENABLED, 'logout' => RoomPage::BUTTON_ENABLED, ]); // After the owner "opened the room" guest should be able to join // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) { $browser->assertVisible('.meet-nickname'); }) ->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) { $browser->assertSeeIn('.meet-nickname', 'john'); }) ->assertElementsCount('@session div.meet-video', 0) ->assertElementsCount('@session video', 0) ->assertElementsCount('@session div.meet-subscriber', 2) ->assertSeeIn('@counter', 2) ->assertToolbar([ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED, 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED, 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'fullscreen' => RoomPage::BUTTON_ENABLED, 'logout' => RoomPage::BUTTON_ENABLED, ]); // Check guest's elements in the owner's window $browser ->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) { $browser->assertVisible('.meet-nickname'); }) ->assertElementsCount('@session div.meet-video', 0) ->assertElementsCount('@session video', 0) ->assertElementsCount('@session .meet-subscriber', 2) ->assertSeeIn('@counter', 2); // Test leaving the room // Guest is leaving $guest->click('@menu button.link-logout') ->waitForLocation('/login'); // Expect the participant removed from other users windows $browser->waitUntilMissing('@session .meet-subscriber:not(.self)'); }); } /** * Test demoting publisher to a subscriber * * @group meet * @depends testSubscribers */ public function testDemoteToSubscriber(): void { $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) { // Join the room as the owner $browser->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->type('@setup-nickname-input', 'john') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('@session video'); // In one browser window act as a guest $guest1->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('div.meet-video.self') ->waitFor('div.meet-video:not(.self)') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0) // assert there's no moderator-related features for this guess available ->click('@session .meet-video.self .meet-nickname') ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { $browser->assertMissing('.permissions'); }) ->click('@session .meet-video:not(.self) .meet-nickname') ->pause(50) ->assertMissing('.dropdown-menu'); // Demote the guest to a subscriber $browser ->waitFor('div.meet-video.self video') ->waitFor('div.meet-video:not(.self) video') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session .meet-subscriber', 0) ->click('@session .meet-video:not(.self) .meet-nickname') ->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitUntilMissing('@session .meet-video:not(.self)') ->waitFor('@session div.meet-subscriber') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 1); $guest1 ->waitUntilMissing('@session .meet-video.self') ->waitFor('@session div.meet-subscriber') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 1); // Join as another user to make sure the role change is propagated to new connections $guest2->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('div.meet-subscriber:not(.self)') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 2) ->click('@toolbar .link-logout'); // Promote the guest back to a publisher $browser ->click('@session .meet-subscriber .meet-nickname') ->whenAvailable('@session .meet-subscriber .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->assertNotChecked('.action-role-publisher input') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitFor('@session .meet-video:not(.self) video') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0); $guest1 ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Media setup') ->click('@button-cancel'); }) ->waitFor('@session .meet-video.self') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0); // Demote the owner to a subscriber $browser ->click('@session .meet-video.self .meet-nickname') ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->assertChecked('.action-role-publisher input') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitUntilMissing('@session .meet-video.self') ->waitFor('@session div.meet-subscriber.self') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 1); // Promote the owner to a publisher $browser ->click('@session .meet-subscriber.self .meet-nickname') ->whenAvailable('@session .meet-subscriber.self .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->assertNotChecked('.action-role-publisher input') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitUntilMissing('@session .meet-subscriber.self') ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Media setup') ->click('@button-cancel'); }) ->waitFor('@session div.meet-video.self') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0); }); } /** * Test the media setup dialog * * @group meet * @depends testDemoteToSubscriber */ public function testMediaSetupDialog(): void { $this->browse(function (Browser $browser, $guest) { // Join the room as the owner $browser->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->type('@setup-nickname-input', 'john') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form'); // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form'); $browser->waitFor('@session video') ->click('.controls button.link-setup') ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Media setup') ->assertVisible('form video') ->assertVisible('form > div:nth-child(1) video') ->assertVisible('form > div:nth-child(1) .volume') ->assertVisible('form > div:nth-child(2) svg') ->assertAttribute('form > div:nth-child(2) .input-group-text', 'title', 'Microphone') ->assertVisible('form > div:nth-child(2) select') ->assertVisible('form > div:nth-child(3) svg') ->assertAttribute('form > div:nth-child(3) .input-group-text', 'title', 'Camera') ->assertVisible('form > div:nth-child(3) select') ->assertSeeIn('@button-cancel', 'Close') ->click('@button-cancel'); }) ->assertMissing('#media-setup-dialog') // Test mute audio and video ->click('.controls button.link-setup') ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->select('form > div:nth-child(2) select', '') ->select('form > div:nth-child(3) select', '') ->click('@button-cancel'); }) ->assertMissing('#media-setup-dialog') ->assertVisible('@session .meet-video .status .status-audio') ->assertVisible('@session .meet-video .status .status-video'); $guest->waitFor('@session video') ->waitFor('@session .meet-video .status .status-audio') ->assertVisible('@session .meet-video .status .status-video'); }); } } diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php index ca223260..eb5eee09 100644 --- a/src/tests/Browser/PaymentMollieTest.php +++ b/src/tests/Browser/PaymentMollieTest.php @@ -1,304 +1,304 @@ deleteTestUser('payment-test@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('payment-test@kolabnow.com'); parent::tearDown(); } /** * Test the payment process * * @group mollie */ public function testPayment(): void { $user = $this->getTestUser('payment-test@kolabnow.com', [ 'password' => 'simple123', ]); $this->browse(function (Browser $browser) use ($user) { $browser->withConfig(['services.payment_provider' => 'mollie']) ->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@main button', 'Add credit') ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->waitFor('#payment-method-selection .link-creditcard svg') ->waitFor('#payment-method-selection .link-paypal svg') ->waitFor('#payment-method-selection .link-banktransfer svg') ->click('#payment-method-selection .link-creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertFocused('#amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #amount', 'aaa') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') // Submit valid data ->type('@body #amount', '12.34') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentMollie()) ->assertSeeIn('@title', $user->tenant->title . ' Payment') ->assertSeeIn('@amount', 'CHF 12.34') ->submitPayment() ->waitForLocation('/wallet') ->on(new WalletPage()) ->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF'); $this->assertSame(1, $user->wallets()->first()->payments()->count()); }); } /** * Test the auto-payment setup process * * @group mollie */ public function testAutoPaymentSetup(): void { $user = $this->getTestUser('payment-test@kolabnow.com', [ 'password' => 'simple123', ]); $this->browse(function (Browser $browser) use ($user) { $browser->withConfig(['services.payment_provider' => 'mollie']) ->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertMissing('@body #mandate-form .alert') ->click('@main #mandate-form button') /* ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection .link-creditcard svg') ->assertMissing('#payment-method-selection .link-paypal') ->assertMissing('#payment-method-selection .link-banktransfer') ->click('#payment-method-selection .link-creditcard'); }) */ ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('@body #mandate_amount') ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') - ->assertValue('@body #mandate_amount', Payment::MIN_AMOUNT / 100) + ->assertValue('@body #mandate_amount', strval(Payment::MIN_AMOUNT / 100)) ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #mandate_amount', 'aaa') ->type('@body #mandate_balance', '-1') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertVisible('@body #mandate_balance.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') ->type('@body #mandate_amount', 'aaa') ->type('@body #mandate_balance', '0') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertMissing('@body #mandate_balance.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') ->assertMissing('#mandate_balance + span + .invalid-feedback') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentMollie()) ->assertSeeIn('@title', $user->tenant->title . ' Auto-Payment Setup') ->assertMissing('@amount') ->submitPayment() ->waitForLocation('/wallet') ->visit('/wallet') ->waitFor('#mandate-info') ->assertPresent('#mandate-info p:first-child') ->assertSeeIn( '#mandate-info p:first-child', 'Auto-payment is set to fill up your account by 100 CHF ' . 'every time your account balance gets under 0 CHF.' ) ->assertSeeIn( '#mandate-info p:nth-child(2)', 'Mastercard (**** **** **** 9399)' ) ->assertMissing('@body .alert'); $this->assertSame(1, $user->wallets()->first()->payments()->count()); }); // Test updating (disabled) auto-payment $this->browse(function (Browser $browser) use ($user) { $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $browser->refresh() ->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn( '#mandate-info .disabled-mandate', 'The configured auto-payment has been disabled' ) ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment') ->click('#mandate-info button.btn-primary') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Update auto-payment') ->assertSeeIn( '@body form .disabled-mandate', 'The auto-payment is disabled.' ) ->assertValue('@body #mandate_amount', '100') ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') // Test error handling ->type('@body #mandate_amount', 'aaa') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') // Submit valid data ->type('@body #mandate_amount', '50') ->click('@button-action'); }) ->waitUntilMissing('#payment-dialog') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.') // make sure the "disabled" text isn't there ->assertMissing('#mandate-info .disabled-mandate') ->click('#mandate-info button.btn-primary') ->assertMissing('form .disabled-mandate') ->click('button.modal-cancel'); }); // Test deleting auto-payment $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment') ->assertVisible('#mandate-info * button.btn-danger') ->click('#mandate-info * button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertVisible('#mandate-form') ->assertMissing('#mandate-info'); }); // Test pending and failed mandate $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->assertMissing('@body #mandate-form .alert') ->click('@main #mandate-form button') /* ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection .link-creditcard') ->click('#payment-method-selection .link-creditcard'); }) */ ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('@body #mandate_amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') ->click('@button-action'); }) ->on(new PaymentMollie()) ->submitPayment('open') ->waitForLocation('/wallet') ->visit('/wallet') ->on(new WalletPage()) ->assertSeeIn( '#mandate-info .alert-warning', 'The setup of the automatic payment is still in progress.' ) // Delete the mandate ->click('#mandate-info * button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertMissing('@body #mandate-form .alert') // Create a new mandate ->click('@main #mandate-form button') /* ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection .link-creditcard') ->click('#payment-method-selection .link-creditcard'); }) */ ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('@body #mandate_amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') ->click('@button-action'); }) ->on(new PaymentMollie()) ->submitPayment('failed') ->waitForLocation('/wallet') ->visit('/wallet') ->on(new WalletPage()) ->waitFor('#mandate-form .alert-danger') ->assertSeeIn( '#mandate-form .alert-danger', 'The setup of automatic payments failed. Restart the process to enable' ) ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->waitFor('#mandate-form') ->assertMissing('#mandate-info'); }); }); } } diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php index 5baa7005..1a6a124e 100644 --- a/src/tests/Browser/PaymentStripeTest.php +++ b/src/tests/Browser/PaymentStripeTest.php @@ -1,239 +1,239 @@ deleteTestUser('payment-test@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('payment-test@kolabnow.com'); parent::tearDown(); } /** * Test the payment process * * @group stripe */ public function testPayment(): void { $user = $this->getTestUser('payment-test@kolabnow.com', [ 'password' => 'simple123', ]); $this->browse(function (Browser $browser) use ($user) { $browser->withConfig(['services.payment_provider' => 'stripe']) ->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@main button', 'Add credit') ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->waitFor('#payment-method-selection .link-creditcard svg') ->waitFor('#payment-method-selection .link-paypal svg') ->assertMissing('#payment-method-selection .link-banktransfer svg') ->click('#payment-method-selection .link-creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertFocused('#amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #amount', 'aaa') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') // Submit valid data ->type('@body #amount', '12.34') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentStripe()) ->assertSeeIn('@title', $user->tenant->title . ' Payment') ->assertSeeIn('@amount', 'CHF 12.34') ->assertSeeIn('@email', $user->email) ->submitValidCreditCard(); // Now it should redirect back to wallet page and in background // use the webhook to update payment status (and balance). // Looks like in test-mode the webhook is executed before redirect // so we can expect balance updated on the wallet page $browser->waitForLocation('/wallet', 30) // need more time than default 5 sec. ->on(new WalletPage()) ->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF'); }); } /** * Test the auto-payment setup process * * @group stripe */ public function testAutoPaymentSetup(): void { $user = $this->getTestUser('payment-test@kolabnow.com', [ 'password' => 'simple123', ]); // Test creating auto-payment $this->browse(function (Browser $browser) use ($user) { $browser->withConfig(['services.payment_provider' => 'stripe']) ->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertMissing('@body #mandate-form .alert') ->click('@main #mandate-form button') /* ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection .link-creditcard') ->assertMissing('#payment-method-selection .link-paypal') ->assertMissing('#payment-method-selection .link-banktransfer') ->click('#payment-method-selection .link-creditcard'); }) */ ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('@body #mandate_amount') ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') - ->assertValue('@body #mandate_amount', Payment::MIN_AMOUNT / 100) + ->assertValue('@body #mandate_amount', strval(Payment::MIN_AMOUNT / 100)) ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #mandate_amount', 'aaa') ->type('@body #mandate_balance', '-1') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertVisible('@body #mandate_balance.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') ->type('@body #mandate_amount', 'aaa') ->type('@body #mandate_balance', '0') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertMissing('@body #mandate_balance.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') ->assertMissing('#mandate_balance + span + .invalid-feedback') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentStripe()) ->assertMissing('@title') ->assertMissing('@amount') ->assertSeeIn('@email', $user->email) ->submitValidCreditCard() ->waitForLocation('/wallet', 30) // need more time than default 5 sec. ->visit('/wallet') ->waitFor('#mandate-info') ->assertPresent('#mandate-info p:first-child') ->assertSeeIn( '#mandate-info p:first-child', 'Auto-payment is set to fill up your account by 100 CHF ' . 'every time your account balance gets under 0 CHF.' ) ->assertSeeIn( '#mandate-info p:nth-child(2)', 'Visa (**** **** **** 4242)' ) ->assertMissing('@body .alert'); }); // Test updating (disabled) auto-payment $this->browse(function (Browser $browser) use ($user) { $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $browser->refresh() ->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn( '#mandate-info .disabled-mandate', 'The configured auto-payment has been disabled' ) ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment') ->click('#mandate-info button.btn-primary') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Update auto-payment') ->assertSeeIn( '@body form .disabled-mandate', 'The auto-payment is disabled.' ) ->assertValue('@body #mandate_amount', '100') ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') // Test error handling ->type('@body #mandate_amount', 'aaa') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') // Submit valid data ->type('@body #mandate_amount', '50') ->click('@button-action'); }) ->waitUntilMissing('#payment-dialog') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.') // make sure the "disabled" text isn't there ->assertMissing('#mandate-info .disabled-mandate') ->click('#mandate-info button.btn-primary') ->assertMissing('form .disabled-mandate') ->click('button.modal-cancel'); }); // Test deleting auto-payment $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment') ->assertVisible('#mandate-info * button.btn-danger') ->click('#mandate-info * button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertVisible('#mandate-form') ->assertMissing('#mandate-info'); }); } } diff --git a/src/tests/Browser/Reseller/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php index a940e951..c0fe95ce 100644 --- a/src/tests/Browser/Reseller/InvitationsTest.php +++ b/src/tests/Browser/Reseller/InvitationsTest.php @@ -1,225 +1,225 @@ browse(function (Browser $browser) { $browser->visit('/invitations')->on(new Home()); }); } /** * Test Invitations creation */ public function testInvitationCreate(): void { $this->browse(function (Browser $browser) { $date_regexp = '/^20[0-9]{2}-/'; $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertSeeIn('@links .link-invitations', 'Invitations') ->click('@links .link-invitations') ->on(new Invitations()) ->assertElementsCount('@table tbody tr', 0) ->assertMissing('.more-loader') ->assertSeeIn('@table tfoot td', "There are no invitations in the database.") ->assertSeeIn('@create-button', 'Create invite(s)'); // Create a single invite with email address input $browser->click('@create-button') ->with(new Dialog('#invite-create'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Invite for a signup') ->assertFocused('@body input#email') ->assertValue('@body input#email', '') ->type('@body input#email', 'test') ->assertSeeIn('@button-action', 'Send invite(s)') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, "Form validation error") ->waitFor('@body input#email.is-invalid') ->assertSeeIn( '@body input#email.is-invalid + .invalid-feedback', "The email must be a valid email address." ) ->type('@body input#email', 'test@domain.tld') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.") ->waitUntilMissing('#invite-create') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 1) ->assertMissing('@table tfoot') ->assertSeeIn('@table tbody tr td.email', 'test@domain.tld') ->assertText('@table tbody tr td.email title', 'Not sent yet') ->assertTextRegExp('@table tbody tr td.datetime', $date_regexp) ->assertVisible('@table tbody tr td.buttons button.button-delete') ->assertVisible('@table tbody tr td.buttons button.button-resend:disabled'); sleep(1); // Create invites from a file $browser->click('@create-button') ->with(new Dialog('#invite-create'), function (Browser $browser) { $browser->assertFocused('@body input#email') ->assertValue('@body input#email', '') ->assertMissing('@body input#email.is-invalid') // Submit an empty file ->attach('@body input#file', __DIR__ . '/../../data/empty.csv') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, "Form validation error") // ->waitFor('input#file.is-invalid') ->assertSeeIn( '@body input#file.is-invalid + .invalid-feedback', "Failed to find any valid email addresses in the uploaded file." ) // Submit non-empty file ->attach('@body input#file', __DIR__ . '/../../data/email.csv') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.") ->waitUntilMissing('#invite-create') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 3) ->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/') ->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/'); }); } /** * Test Invitations deletion and resending */ public function testInvitationDeleteAndResend(): void { $this->browse(function (Browser $browser) { Queue::fake(); $i1 = SignupInvitation::create(['email' => 'test1@domain.org']); $i2 = SignupInvitation::create(['email' => 'test2@domain.org']); SignupInvitation::where('id', $i1->id)->update(['status' => SignupInvitation::STATUS_FAILED]); - SignupInvitation::where('id', $i2->id)->update(['created_at' => now()->subHours('2')]); + SignupInvitation::where('id', $i2->id)->update(['created_at' => now()->subHours(2)]); $browser->visit(new Invitations()) ->assertElementsCount('@table tbody tr', 2); // Test resending $browser->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org') ->click('@table tbody tr:first-child button.button-resend') ->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.") ->assertVisible('@table tbody tr:first-child button.button-resend:disabled') ->assertElementsCount('@table tbody tr', 2); // Test deleting $browser->assertSeeIn('@table tbody tr:last-child td.email', 'test2@domain.org') ->click('@table tbody tr:last-child button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.") ->assertElementsCount('@table tbody tr', 1) ->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org'); }); } /** * Test Invitations list (paging and searching) */ public function testInvitationsList(): void { $this->browse(function (Browser $browser) { Queue::fake(); $i1 = SignupInvitation::create(['email' => 'email1@ext.com']); $i2 = SignupInvitation::create(['email' => 'email2@ext.com']); $i3 = SignupInvitation::create(['email' => 'email3@ext.com']); $i4 = SignupInvitation::create(['email' => 'email4@other.com']); $i5 = SignupInvitation::create(['email' => 'email5@other.com']); $i6 = SignupInvitation::create(['email' => 'email6@other.com']); $i7 = SignupInvitation::create(['email' => 'email7@other.com']); $i8 = SignupInvitation::create(['email' => 'email8@other.com']); $i9 = SignupInvitation::create(['email' => 'email9@other.com']); $i10 = SignupInvitation::create(['email' => 'email10@other.com']); $i11 = SignupInvitation::create(['email' => 'email11@other.com']); - SignupInvitation::query()->update(['created_at' => now()->subDays('1')]); + SignupInvitation::query()->update(['created_at' => now()->subDays(1)]); SignupInvitation::where('id', $i1->id) - ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]); + ->update(['created_at' => now()->subHours(2), 'status' => SignupInvitation::STATUS_FAILED]); SignupInvitation::where('id', $i2->id) - ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]); + ->update(['created_at' => now()->subHours(3), 'status' => SignupInvitation::STATUS_SENT]); SignupInvitation::where('id', $i3->id) - ->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]); - SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]); + ->update(['created_at' => now()->subHours(4), 'status' => SignupInvitation::STATUS_COMPLETED]); + SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays(3)]); // Test paging (load more) feature $browser->visit(new Invitations()) // ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->assertElementsCount('@table tbody tr', 10) ->assertSeeIn('.more-loader button', 'Load more') ->with('@table tbody', function ($browser) use ($i1, $i2, $i3) { $browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email) ->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed') ->assertVisible('tr:nth-child(1) td.buttons button.button-delete') ->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)') ->assertSeeIn('tr:nth-child(2) td.email', $i2->email) ->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent') ->assertVisible('tr:nth-child(2) td.buttons button.button-delete') ->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)') ->assertSeeIn('tr:nth-child(3) td.email', $i3->email) ->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up') ->assertVisible('tr:nth-child(3) td.buttons button.button-delete') ->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled') ->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet') ->assertVisible('tr:nth-child(4) td.buttons button.button-delete') ->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled'); }) ->click('.more-loader button') ->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) { $browser->assertSeeIn('td.email', $i11->email); }) ->assertMissing('.more-loader button'); // Test searching (by domain) $browser->type('@search-input', 'ext.com') ->click('@search-button') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 3) ->assertMissing('.more-loader button') // search by full email ->type('@search-input', 'email7@other.com') ->keys('@search-input', '{enter}') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 1) ->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com') ->assertMissing('.more-loader button') // reset search ->vueClear('#search-form input') ->keys('@search-input', '{enter}') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 10) ->assertVisible('.more-loader button'); }); } } diff --git a/src/tests/Feature/Controller/Reseller/InvitationsTest.php b/src/tests/Feature/Controller/Reseller/InvitationsTest.php index b25bb849..d882b4bd 100644 --- a/src/tests/Feature/Controller/Reseller/InvitationsTest.php +++ b/src/tests/Feature/Controller/Reseller/InvitationsTest.php @@ -1,357 +1,357 @@ ) */ public function testDestroy(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@sample-tenant.dev-local'); $reseller2 = $this->getTestUser('reseller@' . \config('app.domain')); $inv = SignupInvitation::create(['email' => 'email1@ext.com']); $inv->tenant_id = $reseller->tenant_id; $inv->save(); // Non-admin user $response = $this->actingAs($user)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(403); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(404); // Reseller - non-existing invitation identifier $response = $this->actingAs($reseller)->delete("api/v4/invitations/abd"); $response->assertStatus(404); // Reseller - existing invitation $response = $this->actingAs($reseller)->delete("api/v4/invitations/{$inv->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Invitation deleted successfully.", $json['message']); $this->assertSame(null, SignupInvitation::find($inv->id)); } /** * Test listing invitations (GET /api/v4/invitations) */ public function testIndex(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $tenant = Tenant::where('title', 'Sample Tenant')->first(); // Non-admin user $response = $this->actingAs($user)->get("api/v4/invitations"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/invitations"); $response->assertStatus(403); // Reseller (empty list) $response = $this->actingAs($reseller)->get("api/v4/invitations"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertFalse($json['hasMore']); // Add some invitations $i1 = SignupInvitation::create(['email' => 'email1@ext.com']); $i2 = SignupInvitation::create(['email' => 'email2@ext.com']); $i3 = SignupInvitation::create(['email' => 'email3@ext.com']); $i4 = SignupInvitation::create(['email' => 'email4@other.com']); $i5 = SignupInvitation::create(['email' => 'email5@other.com']); $i6 = SignupInvitation::create(['email' => 'email6@other.com']); $i7 = SignupInvitation::create(['email' => 'email7@other.com']); $i8 = SignupInvitation::create(['email' => 'email8@other.com']); $i9 = SignupInvitation::create(['email' => 'email9@other.com']); $i10 = SignupInvitation::create(['email' => 'email10@other.com']); $i11 = SignupInvitation::create(['email' => 'email11@other.com']); $i12 = SignupInvitation::create(['email' => 'email12@test.com']); $i13 = SignupInvitation::create(['email' => 'email13@ext.com']); - SignupInvitation::query()->update(['created_at' => now()->subDays('1')]); + SignupInvitation::query()->update(['created_at' => now()->subDays(1)]); SignupInvitation::where('id', $i1->id) - ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]); + ->update(['created_at' => now()->subHours(2), 'status' => SignupInvitation::STATUS_FAILED]); SignupInvitation::where('id', $i2->id) - ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]); + ->update(['created_at' => now()->subHours(3), 'status' => SignupInvitation::STATUS_SENT]); - SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]); + SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays(3)]); SignupInvitation::where('id', $i12->id)->update(['tenant_id' => $reseller2->tenant_id]); SignupInvitation::where('id', $i13->id)->update(['tenant_id' => $reseller2->tenant_id]); $response = $this->actingAs($reseller)->get("api/v4/invitations"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(10, $json['count']); $this->assertSame(1, $json['page']); $this->assertTrue($json['hasMore']); $this->assertSame($i1->id, $json['list'][0]['id']); $this->assertSame($i1->email, $json['list'][0]['email']); $this->assertSame(true, $json['list'][0]['isFailed']); $this->assertSame(false, $json['list'][0]['isNew']); $this->assertSame(false, $json['list'][0]['isSent']); $this->assertSame(false, $json['list'][0]['isCompleted']); $this->assertSame($i2->id, $json['list'][1]['id']); $this->assertSame($i2->email, $json['list'][1]['email']); $this->assertFalse(in_array($i12->email, array_column($json['list'], 'email'))); $this->assertFalse(in_array($i13->email, array_column($json['list'], 'email'))); $response = $this->actingAs($reseller)->get("api/v4/invitations?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertSame(2, $json['page']); $this->assertFalse($json['hasMore']); $this->assertSame($i11->id, $json['list'][0]['id']); // Test searching (email address) $response = $this->actingAs($reseller)->get("api/v4/invitations?search=email3@ext.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertSame(1, $json['page']); $this->assertFalse($json['hasMore']); $this->assertSame($i3->id, $json['list'][0]['id']); // Test searching (domain) $response = $this->actingAs($reseller)->get("api/v4/invitations?search=ext.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(3, $json['count']); $this->assertSame(1, $json['page']); $this->assertFalse($json['hasMore']); $this->assertSame($i1->id, $json['list'][0]['id']); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->get("api/v4/invitations"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); } /** * Test resending invitations (POST /api/v4/invitations//resend) */ public function testResend(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@sample-tenant.dev-local'); $reseller2 = $this->getTestUser('reseller@' . \config('app.domain')); $tenant = Tenant::where('title', 'Sample Tenant')->first(); $inv = SignupInvitation::create(['email' => 'email1@ext.com']); $inv->tenant_id = $reseller->tenant_id; $inv->save(); SignupInvitation::where('id', $inv->id)->update(['status' => SignupInvitation::STATUS_FAILED]); // Non-admin user $response = $this->actingAs($user)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(403); // Reseller user, but different tenant $response = $this->actingAs($reseller2)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(404); // Reseller - non-existing invitation identifier $response = $this->actingAs($reseller)->post("api/v4/invitations/abd/resend"); $response->assertStatus(404); // Reseller - existing invitation $response = $this->actingAs($reseller)->post("api/v4/invitations/{$inv->id}/resend"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Invitation added to the sending queue successfully.", $json['message']); $this->assertTrue($inv->fresh()->isNew()); } /** * Test creating invitations (POST /api/v4/invitations) */ public function testStore(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@sample-tenant.dev-local'); $reseller2 = $this->getTestUser('reseller@' . \config('app.domain')); $tenant = Tenant::where('title', 'Sample Tenant')->first(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/invitations", []); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->post("api/v4/invitations", []); $response->assertStatus(403); // Reseller (empty post) $response = $this->actingAs($reseller)->post("api/v4/invitations", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The email field is required.", $json['errors']['email'][0]); // Invalid email address $post = ['email' => 'test']; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The email must be a valid email address.", $json['errors']['email'][0]); // Valid email address $post = ['email' => 'test@external.org']; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("The invitation has been created.", $json['message']); $this->assertSame(1, $json['count']); $this->assertSame(1, SignupInvitation::count()); $invitation = SignupInvitation::first(); $this->assertSame($reseller->tenant_id, $invitation->tenant_id); $this->assertSame($post['email'], $invitation->email); // Test file input (empty file) $tmpfile = tmpfile(); fwrite($tmpfile, ""); $file = new File('test.csv', $tmpfile); $post = ['file' => $file]; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); fclose($tmpfile); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Failed to find any valid email addresses in the uploaded file.", $json['errors']['file']); // Test file input with an invalid email address $tmpfile = tmpfile(); fwrite($tmpfile, "t1@domain.tld\r\nt2@domain"); $file = new File('test.csv', $tmpfile); $post = ['file' => $file]; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); fclose($tmpfile); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Found an invalid email address (t2@domain) on line 2.", $json['errors']['file']); // Test file input (two addresses) $tmpfile = tmpfile(); fwrite($tmpfile, "t1@domain.tld\r\nt2@domain.tld"); $file = new File('test.csv', $tmpfile); $post = ['file' => $file]; $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); fclose($tmpfile); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, SignupInvitation::where('email', 't1@domain.tld')->count()); $this->assertSame(1, SignupInvitation::where('email', 't2@domain.tld')->count()); $this->assertSame('success', $json['status']); $this->assertSame("2 invitations has been created.", $json['message']); $this->assertSame(2, $json['count']); // Reseller user, but different tenant $post = ['email' => 'test-reseller2@external.org']; $response = $this->actingAs($reseller2)->post("api/v4/invitations", $post); $response->assertStatus(200); $invitation = SignupInvitation::where('email', $post['email'])->first(); $this->assertSame($reseller2->tenant_id, $invitation->tenant_id); } } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index 8c9acae2..5dc33aa7 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,171 +1,171 @@ deleteTestUser('jane@kolabnow.com'); $this->clearBetaEntitlements(); Sku::where('title', 'test')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->clearBetaEntitlements(); Sku::where('title', 'test')->delete(); parent::tearDown(); } /** * Test fetching SKUs list */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($john)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(11, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('Mailbox', $json[0]['handler']); // Test the type filter, and nextCost property (user with one domain) $response = $this->actingAs($john)->get("api/v4/skus?type=domain"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('domain-hosting', $json[0]['title']); $this->assertSame(100, $json[0]['nextCost']); // second domain costs 100 // Test the type filter, and nextCost property (user with no domain) $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $jane->assignPackage($kolab); $response = $this->actingAs($jane)->get("api/v4/skus?type=domain"); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('domain-hosting', $json[0]['title']); $this->assertSame(0, $json[0]['nextCost']); // first domain costs 0 } /** * Test updateEntitlements() method */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $wallet = $jane->wallets()->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Invalid empty input - SkusController::updateEntitlements($jane, null, $wallet); + SkusController::updateEntitlements($jane, [], $wallet); $this->assertSame(0, $wallet->entitlements()->count()); // Add mailbox SKU SkusController::updateEntitlements($jane, [$mailbox_sku->id => 1], $wallet); $this->assertSame(1, $wallet->entitlements()->count()); $this->assertSame($mailbox_sku->id, $wallet->entitlements()->first()->sku_id); // Add 2 storage SKUs $skus = [$mailbox_sku->id => 1, $storage_sku->id => 2]; SkusController::updateEntitlements($jane, $skus, $wallet); $this->assertSame(1, $wallet->entitlements()->where('sku_id', $mailbox_sku->id)->count()); $this->assertSame(2, $wallet->entitlements()->where('sku_id', $storage_sku->id)->count()); // Add two more storage SKUs $skus = [$mailbox_sku->id => 1, $storage_sku->id => 7]; SkusController::updateEntitlements($jane, $skus, $wallet); $this->assertSame(1, $wallet->entitlements()->where('sku_id', $mailbox_sku->id)->count()); $this->assertSame(7, $wallet->entitlements()->where('sku_id', $storage_sku->id)->count()); // Remove two storage SKUs $skus = [$mailbox_sku->id => 1, $storage_sku->id => 3]; SkusController::updateEntitlements($jane, $skus, $wallet); $this->assertSame(1, $wallet->entitlements()->where('sku_id', $mailbox_sku->id)->count()); // Note: 5 not 4 because of free_units=5 $this->assertSame(5, $wallet->entitlements()->where('sku_id', $storage_sku->id)->count()); // Request SKU that can't be assigned to a User object // Such SKUs are being ignored silently $group_sku = Sku::withEnvTenantContext()->where('title', 'group')->first(); $skus = [$mailbox_sku->id => 1, $storage_sku->id => 5, $group_sku->id => 1]; SkusController::updateEntitlements($jane, $skus, $wallet); $this->assertSame(0, $wallet->entitlements()->where('sku_id', $group_sku->id)->count()); // Error - add extra mailbox SKU $this->expectException(\Exception::class); $this->expectExceptionMessage('Invalid quantity of mailboxes'); $skus = [$mailbox_sku->id => 2, $storage_sku->id => 5]; SkusController::updateEntitlements($jane, $skus, $wallet); // Error - disabled subscriptions $this->expectException(\Exception::class); $this->expectExceptionMessage('Subscriptions disabled'); \config(['app.with_subscriptions' => false]); $skus = [$mailbox_sku->id => 1]; SkusController::updateEntitlements($jane, $skus, $wallet); } } diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php index 748c55af..e3b15dec 100644 --- a/src/tests/Feature/GroupTest.php +++ b/src/tests/Feature/GroupTest.php @@ -1,450 +1,450 @@ deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); parent::tearDown(); } /** * Tests for Group::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $result = $group->assignToWallet($user->wallets->first()); $this->assertSame($group, $result); $this->assertSame(1, $group->entitlements()->count()); // Can't be done twice on the same group $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Group::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $group->setSetting('sender_policy', '["test","-"]'); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $result = $group->setConfig(['sender_policy' => [], 'unknown' => false]); $this->assertSame(['sender_policy' => []], $group->getConfig()); $this->assertSame('[]', $group->getSetting('sender_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $group->setConfig(['sender_policy' => ['test']]); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $this->assertSame('["test","-"]', $group->getSetting('sender_policy')); $this->assertSame([], $result); } /** * Test creating a group */ public function testCreate(): void { Queue::fake(); $group = Group::create(['email' => 'GROUP-test@kolabnow.com']); $this->assertSame('group-test@kolabnow.com', $group->email); $this->assertSame('group-test', $group->name); - $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $group->id); + $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', (string) $group->id); $this->assertSame([], $group->members); $this->assertTrue($group->isNew()); $this->assertFalse($group->isActive()); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test group deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $group->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Group\DeleteJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test eventlog on group deletion */ public function testDeleteAndEventLog(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); EventLog::createFor($group, EventLog::TYPE_SUSPENDED, 'test'); $group->delete(); $this->assertCount(1, EventLog::where('object_id', $group->id)->where('object_type', Group::class)->get()); $group->forceDelete(); $this->assertCount(0, EventLog::where('object_id', $group->id)->where('object_type', Group::class)->get()); } /** * Tests for Group::emailExists() */ public function testEmailExists(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $this->assertFalse(Group::emailExists('unknown@domain.tld')); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); $group->delete(); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); } /* * Test group restoring */ public function testRestore(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com', [ 'status' => Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY | Group::STATUS_SUSPENDED, ]); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertTrue($group->isSuspended()); if (\config('app.with_ldap')) { $this->assertTrue($group->isLdapReady()); } $this->assertTrue($group->isActive()); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); Queue::fake(); $group->restore(); $group->refresh(); $this->assertFalse($group->trashed()); $this->assertFalse($group->isDeleted()); $this->assertFalse($group->isSuspended()); if (\config('app.with_ldap')) { $this->assertFalse($group->isLdapReady()); } $this->assertFalse($group->isActive()); $this->assertTrue($group->isNew()); $this->assertSame(1, $entitlements->count()); $entitlements->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); Queue::assertPushed(\App\Jobs\Group\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for GroupSettingsTrait functionality and GroupSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $group = $this->getTestGroup('group-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting $group->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting that is synced to LDAP $group->setSetting('sender_policy', '[]'); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); } // Note: We test both current group as well as fresh group object // to make sure cache works as expected $this->assertSame('test', $group->getSetting('unknown')); $this->assertSame('[]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Update a setting $group->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Update a setting that is synced to LDAP $group->setSetting('sender_policy', '["-"]'); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); } $this->assertSame('test1', $group->getSetting('unknown')); $this->assertSame('["-"]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Delete a setting (null) $group->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $group->setSetting('sender_policy', null); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); } $this->assertSame(null, $group->getSetting('unknown')); $this->assertSame(null, $group->fresh()->getSetting('sender_policy')); } /** * Test group status assignment and is*() methods */ public function testStatus(): void { $group = new Group(); $this->assertSame(false, $group->isNew()); $this->assertSame(false, $group->isActive()); $this->assertSame(false, $group->isDeleted()); if (\config('app.with_ldap')) { $this->assertSame(false, $group->isLdapReady()); } $this->assertSame(false, $group->isSuspended()); $group->status = Group::STATUS_NEW; $this->assertSame(true, $group->isNew()); $this->assertSame(false, $group->isActive()); $this->assertSame(false, $group->isDeleted()); if (\config('app.with_ldap')) { $this->assertSame(false, $group->isLdapReady()); } $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_ACTIVE; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(false, $group->isDeleted()); if (\config('app.with_ldap')) { $this->assertSame(false, $group->isLdapReady()); } $this->assertSame(false, $group->isSuspended()); if (\config('app.with_ldap')) { $group->status |= Group::STATUS_LDAP_READY; } $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(false, $group->isDeleted()); if (\config('app.with_ldap')) { $this->assertSame(true, $group->isLdapReady()); } $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_DELETED; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(true, $group->isDeleted()); if (\config('app.with_ldap')) { $this->assertSame(true, $group->isLdapReady()); } $this->assertSame(false, $group->isSuspended()); $group->status |= Group::STATUS_SUSPENDED; $this->assertSame(true, $group->isNew()); $this->assertSame(true, $group->isActive()); $this->assertSame(true, $group->isDeleted()); if (\config('app.with_ldap')) { $this->assertSame(true, $group->isLdapReady()); } $this->assertSame(true, $group->isSuspended()); // Unknown status value $this->expectException(\Exception::class); $group->status = 111; } /** * Tests for Group::suspend() */ public function testSuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->suspend(); $this->assertTrue($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test updating a group */ public function testUpdate(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status |= Group::STATUS_DELETED; $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for Group::unsuspend() */ public function testUnsuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status = Group::STATUS_SUSPENDED; $group->unsuspend(); $this->assertFalse($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } } diff --git a/src/tests/Feature/ResourceTest.php b/src/tests/Feature/ResourceTest.php index 343018d2..76b902ea 100644 --- a/src/tests/Feature/ResourceTest.php +++ b/src/tests/Feature/ResourceTest.php @@ -1,366 +1,366 @@ deleteTestUser('user-test@kolabnow.com'); Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) { $this->deleteTestResource($resource->email); }); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) { $this->deleteTestResource($resource->email); }); parent::tearDown(); } /** * Tests for Resource::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $result = $resource->assignToWallet($user->wallets->first()); $this->assertSame($resource, $result); $this->assertSame(1, $resource->entitlements()->count()); // Can't be done twice on the same resource $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Resource::getConfig() and setConfig() methods */ public function testConfigTrait(): void { Queue::fake(); $resource = new Resource(); $resource->email = 'resource-test@kolabnow.com'; $resource->name = 'Test'; $resource->save(); $john = $this->getTestUser('john@kolab.org'); $resource->assignToWallet($john->wallets->first()); $this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig()); $result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]); $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig()); $this->assertSame('reject', $resource->getSetting('invitation_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $resource->setConfig(['invitation_policy' => 'unknown']); $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig()); $this->assertSame('reject', $resource->getSetting('invitation_policy')); $this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result); // Test valid user for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']); $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig()); $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy')); $this->assertSame([], $result); // Test invalid user email for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:john']); $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig()); $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy')); $this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result); // Test non-existing user for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']); $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result); // Test existing user from a different wallet, for manual invitation policy $result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']); $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result); } /** * Test creating a resource */ public function testCreate(): void { Queue::fake(); $resource = new Resource(); $resource->name = 'Reśo'; $resource->domainName = 'kolabnow.com'; $resource->save(); - $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $resource->id); + $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', (string) $resource->id); $this->assertMatchesRegularExpression('/^resource-[0-9]{1,20}@kolabnow\.com$/', $resource->email); $this->assertSame('Reśo', $resource->name); $this->assertTrue($resource->isNew()); $this->assertFalse($resource->isActive()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($resource->isLdapReady()); $this->assertFalse($resource->isImapReady()); $settings = $resource->settings()->get(); $this->assertCount(1, $settings); $this->assertSame('folder', $settings[0]->key); $this->assertSame('shared/Resources/Reśo@kolabnow.com', $settings[0]->value); Queue::assertPushed( \App\Jobs\Resource\CreateJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } /** * Test resource deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $resource->id); $this->assertSame(1, $entitlements->count()); $resource->delete(); $this->assertTrue($resource->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $resource->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Resource::withTrashed()->where('id', $resource->id)->get()); Queue::assertPushed(\App\Jobs\Resource\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\DeleteJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } /** * Tests for Resource::emailExists() */ public function testEmailExists(): void { Queue::fake(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $this->assertFalse(Resource::emailExists('unknown@domain.tld')); $this->assertTrue(Resource::emailExists($resource->email)); $result = Resource::emailExists($resource->email, true); $this->assertSame($result->id, $resource->id); $resource->delete(); $this->assertTrue(Resource::emailExists($resource->email)); $result = Resource::emailExists($resource->email, true); $this->assertSame($result->id, $resource->id); } /** * Tests for SettingsTrait functionality and ResourceSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $resource = $this->getTestResource('resource-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Add a setting $resource->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Add a setting that is synced to LDAP $resource->setSetting('invitation_policy', 'accept'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\UpdateJob::class, function ($job) use ($resource) { return $resource->id === TestCase::getObjectProperty($job, 'resourceId') && ['invitation_policy' => null] === TestCase::getObjectProperty($job, 'properties'); } ); // Note: We test both current resource as well as fresh resource object // to make sure cache works as expected $this->assertSame('test', $resource->getSetting('unknown')); $this->assertSame('accept', $resource->fresh()->getSetting('invitation_policy')); Queue::fake(); // Update a setting $resource->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Update a setting that is synced to LDAP $resource->setSetting('invitation_policy', 'reject'); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\UpdateJob::class, function ($job) use ($resource) { return $resource->id === TestCase::getObjectProperty($job, 'resourceId') && ['invitation_policy' => 'accept'] === TestCase::getObjectProperty($job, 'properties'); } ); $this->assertSame('test1', $resource->getSetting('unknown')); $this->assertSame('reject', $resource->fresh()->getSetting('invitation_policy')); Queue::fake(); // Delete a setting (null) $resource->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $resource->setSetting('invitation_policy', null); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\UpdateJob::class, function ($job) use ($resource) { return $resource->id === TestCase::getObjectProperty($job, 'resourceId') && ['invitation_policy' => 'reject'] === TestCase::getObjectProperty($job, 'properties'); } ); $this->assertSame(null, $resource->getSetting('unknown')); $this->assertSame(null, $resource->fresh()->getSetting('invitation_policy')); } /** * Test resource status assignment and is*() methods */ public function testStatus(): void { $resource = new Resource(); $this->assertSame(false, $resource->isNew()); $this->assertSame(false, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status = Resource::STATUS_NEW; $this->assertSame(true, $resource->isNew()); $this->assertSame(false, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_ACTIVE; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(false, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_LDAP_READY; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(false, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_DELETED; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(true, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(false, $resource->isImapReady()); $resource->status |= Resource::STATUS_IMAP_READY; $this->assertSame(true, $resource->isNew()); $this->assertSame(true, $resource->isActive()); $this->assertSame(true, $resource->isDeleted()); $this->assertSame(true, $resource->isLdapReady()); $this->assertSame(true, $resource->isImapReady()); // Unknown status value $this->expectException(\Exception::class); $resource->status = 111; } /** * Test updating a resource */ public function testUpdate(): void { Queue::fake(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->name = 'New'; $resource->save(); // Assert the folder changes on a resource name change $settings = $resource->settings()->where('key', 'folder')->get(); $this->assertCount(1, $settings); $this->assertSame('shared/Resources/New@kolabnow.com', $settings[0]->value); Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Resource\UpdateJob::class, function ($job) use ($resource) { $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail'); $resourceId = TestCase::getObjectProperty($job, 'resourceId'); return $resourceEmail === $resource->email && $resourceId === $resource->id; } ); } } diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php index e5dde92a..d129bb64 100644 --- a/src/tests/Feature/SharedFolderTest.php +++ b/src/tests/Feature/SharedFolderTest.php @@ -1,372 +1,372 @@ deleteTestUser('user-test@kolabnow.com'); SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { $this->deleteTestSharedFolder($folder->email); }); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) { $this->deleteTestSharedFolder($folder->email); }); parent::tearDown(); } /** * Tests for AliasesTrait methods */ public function testAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $this->assertCount(0, $folder->aliases->all()); // Add an alias $folder->setAliases(['FolderAlias1@kolabnow.com']); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); $aliases = $folder->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); $this->assertTrue(SharedFolder::aliasExists('folderalias1@kolabnow.com')); // Add another alias $folder->setAliases(['FolderAlias1@kolabnow.com', 'FolderAlias2@kolabnow.com']); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 2); $aliases = $folder->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); $this->assertSame('folderalias2@kolabnow.com', $aliases[1]->alias); // Remove an alias $folder->setAliases(['FolderAlias1@kolabnow.com']); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 3); $aliases = $folder->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias); $this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com')); // Remove all aliases $folder->setAliases([]); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 4); $this->assertCount(0, $folder->aliases()->get()); $this->assertFalse(SharedFolder::aliasExists('folderalias1@kolabnow.com')); $this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com')); } /** * Tests for SharedFolder::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $result = $folder->assignToWallet($user->wallets->first()); $this->assertSame($folder, $result); $this->assertSame(1, $folder->entitlements()->count()); $this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title); // Can't be done twice on the same folder $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test SharedFolder::getConfig() and setConfig() methods */ public function testConfigTrait(): void { Queue::fake(); $folder = new SharedFolder(); $folder->email = 'folder-test@kolabnow.com'; $folder->name = 'Test'; $folder->save(); $john = $this->getTestUser('john@kolab.org'); $folder->assignToWallet($john->wallets->first()); $this->assertSame(['acl' => []], $folder->getConfig()); $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]); $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $folder->setConfig(['acl' => ['anyone, unknown']]); $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig()); $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl')); $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result); // Test valid user for ACL $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); $this->assertSame([], $result); // Test invalid user for ACL $result = $folder->setConfig(['acl' => ['john, full']]); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); $this->assertSame(['acl' => ["The specified email address is invalid."]], $result); // Other invalid entries $acl = [ // Test non-existing user for ACL 'unknown@kolab.org, full', // Test existing user from a different wallet 'user@sample-tenant.dev-local, read-only', // Valid entry 'john@kolab.org, read-write', ]; $result = $folder->setConfig(['acl' => $acl]); $this->assertCount(2, $result['acl']); $this->assertSame("The specified email address does not exist.", $result['acl'][0]); $this->assertSame("The specified email address does not exist.", $result['acl'][1]); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig()); $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl')); } /** * Test creating a shared folder */ public function testCreate(): void { Queue::fake(); $folder = new SharedFolder(); $folder->name = 'Reśo'; $folder->domainName = 'kolabnow.com'; $folder->save(); - $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id); + $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', (string) $folder->id); $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email); $this->assertSame('Reśo', $folder->name); $this->assertTrue($folder->isNew()); $this->assertFalse($folder->isActive()); $this->assertFalse($folder->isDeleted()); $this->assertFalse($folder->isLdapReady()); $this->assertFalse($folder->isImapReady()); $settings = $folder->settings()->get(); $this->assertCount(1, $settings); $this->assertSame('folder', $settings[0]->key); $this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value); Queue::assertPushed( \App\Jobs\SharedFolder\CreateJob::class, function ($job) use ($folder) { $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); $folderId = TestCase::getObjectProperty($job, 'folderId'); return $folderEmail === $folder->email && $folderId === $folder->id; } ); } /** * Test a shared folder deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $folder->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(1, $entitlements->count()); $folder->delete(); $this->assertTrue($folder->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $folder->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get()); Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\SharedFolder\DeleteJob::class, function ($job) use ($folder) { $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); $folderId = TestCase::getObjectProperty($job, 'folderId'); return $folderEmail === $folder->email && $folderId === $folder->id; } ); } /** * Tests for SharedFolder::emailExists() */ public function testEmailExists(): void { Queue::fake(); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $this->assertFalse(SharedFolder::emailExists('unknown@domain.tld')); $this->assertTrue(SharedFolder::emailExists($folder->email)); $result = SharedFolder::emailExists($folder->email, true); $this->assertSame($result->id, $folder->id); $folder->delete(); $this->assertTrue(SharedFolder::emailExists($folder->email)); $result = SharedFolder::emailExists($folder->email, true); $this->assertSame($result->id, $folder->id); } /** * Tests for SettingsTrait functionality and SharedFolderSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); // Add a setting $folder->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); // Add a setting that is synced to LDAP $folder->setSetting('acl', 'test'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\SharedFolder\UpdateJob::class, function ($job) use ($folder) { return $folder->id === TestCase::getObjectProperty($job, 'folderId') && ['acl' => null] === TestCase::getObjectProperty($job, 'properties'); } ); // Note: We test both current folder as well as fresh folder object // to make sure cache works as expected $this->assertSame('test', $folder->getSetting('unknown')); $this->assertSame('test', $folder->fresh()->getSetting('acl')); Queue::fake(); // Update a setting $folder->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); // Update a setting that is synced to LDAP $folder->setSetting('acl', 'test1'); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\SharedFolder\UpdateJob::class, function ($job) use ($folder) { return $folder->id === TestCase::getObjectProperty($job, 'folderId') && ['acl' => 'test'] === TestCase::getObjectProperty($job, 'properties'); } ); $this->assertSame('test1', $folder->getSetting('unknown')); $this->assertSame('test1', $folder->fresh()->getSetting('acl')); Queue::fake(); // Delete a setting (null) $folder->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $folder->setSetting('acl', null); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\SharedFolder\UpdateJob::class, function ($job) use ($folder) { return $folder->id === TestCase::getObjectProperty($job, 'folderId') && ['acl' => 'test1'] === TestCase::getObjectProperty($job, 'properties'); } ); $this->assertSame(null, $folder->getSetting('unknown')); $this->assertSame(null, $folder->fresh()->getSetting('acl')); } /** * Test updating a shared folder */ public function testUpdate(): void { Queue::fake(); $folder = $this->getTestSharedFolder('folder-test@kolabnow.com'); $folder->name = 'New'; $folder->save(); // Assert the imap folder changes on a folder name change $settings = $folder->settings()->where('key', 'folder')->get(); $this->assertCount(1, $settings); $this->assertSame('shared/New@kolabnow.com', $settings[0]->value); Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\SharedFolder\UpdateJob::class, function ($job) use ($folder) { $folderEmail = TestCase::getObjectProperty($job, 'folderEmail'); $folderId = TestCase::getObjectProperty($job, 'folderId'); return $folderEmail === $folder->email && $folderId === $folder->id; } ); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 96d5e1c8..eddd4d39 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1599 +1,1599 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); Package::where('title', 'test-package')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { \App\TenantSetting::truncate(); Package::where('title', 'test-package')->delete(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $skuGroupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); // cost: 490 $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // cost: 500 $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // cost: 25 $package = Package::create([ 'title' => 'test-package', 'name' => 'Test Account', 'description' => 'Test account.', 'discount_rate' => 0, ]); // WARNING: saveMany() sets package_skus.cost = skus.cost $package->skus()->saveMany([ $skuMailbox, $skuGroupware, $skuStorage ]); $package->skus()->updateExistingPivot($skuStorage, ['qty' => 2, 'cost' => null], false); $package->skus()->updateExistingPivot($skuMailbox, ['cost' => null], false); $package->skus()->updateExistingPivot($skuGroupware, ['cost' => 100], false); $user->assignPackage($package); $this->assertCount(4, $user->entitlements()->get()); // mailbox + groupware + 2 x storage $entitlement = $wallet->entitlements()->where('sku_id', $skuMailbox->id)->first(); $this->assertSame($skuMailbox->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame($skuMailbox->cost, $entitlement->cost); $entitlement = $wallet->entitlements()->where('sku_id', $skuGroupware->id)->first(); $this->assertSame($skuGroupware->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame(100, $entitlement->cost); $entitlement = $wallet->entitlements()->where('sku_id', $skuStorage->id)->first(); $this->assertSame($skuStorage->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame(0, $entitlement->cost); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignSku($skuMailbox); $this->assertCount(1, $user->entitlements()->get()); $entitlement = $wallet->entitlements()->where('sku_id', $skuMailbox->id)->first(); $this->assertSame($skuMailbox->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable_id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertSame($skuMailbox->cost, $entitlement->cost); // Test units_free handling for ($x = 0; $x < 5; $x++) { $user->assignSku($skuStorage); } $entitlements = $user->entitlements()->where('sku_id', $skuStorage->id) ->where('cost', 0) ->get(); $this->assertCount(5, $entitlements); $user->assignSku($skuStorage); $entitlements = $user->entitlements()->where('sku_id', $skuStorage->id) ->where('cost', $skuStorage->cost) ->get(); $this->assertCount(1, $entitlements); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } /** * Test User::canDelete() method */ public function testCanDelete(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canDelete($admin)); $this->assertFalse($admin->canDelete($john)); $this->assertFalse($admin->canDelete($jack)); $this->assertFalse($admin->canDelete($reseller1)); $this->assertFalse($admin->canDelete($domain)); $this->assertFalse($admin->canDelete($domain->wallet())); // Reseller - kolabnow $this->assertFalse($reseller1->canDelete($john)); $this->assertFalse($reseller1->canDelete($jack)); $this->assertTrue($reseller1->canDelete($reseller1)); $this->assertFalse($reseller1->canDelete($domain)); $this->assertFalse($reseller1->canDelete($domain->wallet())); $this->assertFalse($reseller1->canDelete($admin)); // Normal user - account owner $this->assertTrue($john->canDelete($john)); $this->assertTrue($john->canDelete($ned)); $this->assertTrue($john->canDelete($jack)); $this->assertTrue($john->canDelete($domain)); $this->assertFalse($john->canDelete($domain->wallet())); $this->assertFalse($john->canDelete($reseller1)); $this->assertFalse($john->canDelete($admin)); // Normal user - a non-owner and non-controller $this->assertFalse($jack->canDelete($jack)); $this->assertFalse($jack->canDelete($john)); $this->assertFalse($jack->canDelete($domain)); $this->assertFalse($jack->canDelete($domain->wallet())); $this->assertFalse($jack->canDelete($reseller1)); $this->assertFalse($jack->canDelete($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canDelete($ned)); $this->assertTrue($ned->canDelete($john)); $this->assertTrue($ned->canDelete($jack)); $this->assertTrue($ned->canDelete($domain)); $this->assertFalse($ned->canDelete($domain->wallet())); $this->assertFalse($ned->canDelete($reseller1)); $this->assertFalse($ned->canDelete($admin)); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user created/creating/updated observers */ public function testCreateAndUpdate(): void { Queue::fake(); $domain = \config('app.domain'); - \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 0); + \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', '0'); $user = User::create([ 'email' => 'USER-test@' . \strtoupper($domain), 'password' => 'test', ]); $result = User::where('email', "user-test@$domain")->first(); $this->assertSame("user-test@$domain", $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW, $result->status); $this->assertSame(0, $user->passwords()->count()); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Test invoking KeyCreateJob $this->deleteTestUser("user-test@$domain"); - \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); + \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', '1'); $user = User::create(['email' => "user-test@$domain", 'password' => 'test']); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password change $user->setSetting('password_expiration_warning', '2020-10-10 10:10:10'); $oldPassword = $user->password; $user->password = 'test123'; $user->save(); $this->assertNotEquals($oldPassword, $user->password); $this->assertSame(0, $user->passwords()->count()); $this->assertNull($user->getSetting('password_expiration_warning')); $this->assertMatchesRegularExpression( '/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $user->getSetting('password_update') ); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); // Update the user, test the password history $user->setSetting('password_policy', 'last:3'); $oldPassword = $user->password; $user->password = 'test1234'; $user->save(); $this->assertSame(1, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->first()->password); $user->password = 'test12345'; $user->save(); $oldPassword = $user->password; $user->password = 'test123456'; $user->save(); $this->assertSame(2, $user->passwords()->count()); $this->assertSame($oldPassword, $user->passwords()->latest()->first()->password); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $user->setSetting('greylist_enabled', null); $user->setSetting('guam_enabled', null); $user->setSetting('password_policy', null); $user->setSetting('max_password_age', null); $user->setSetting('limit_geo', null); // greylist_enabled $this->assertSame(true, $user->getConfig()['greylist_enabled']); $result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $this->assertSame(false, $user->getConfig()['greylist_enabled']); $this->assertSame('false', $user->getSetting('greylist_enabled')); $result = $user->setConfig(['greylist_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $user->getConfig()['greylist_enabled']); $this->assertSame('true', $user->getSetting('greylist_enabled')); // guam_enabled $this->assertSame(false, $user->getConfig()['guam_enabled']); $result = $user->setConfig(['guam_enabled' => false]); $this->assertSame([], $result); $this->assertSame(false, $user->getConfig()['guam_enabled']); $this->assertSame(null, $user->getSetting('guam_enabled')); $result = $user->setConfig(['guam_enabled' => true]); $this->assertSame([], $result); $this->assertSame(true, $user->getConfig()['guam_enabled']); $this->assertSame('true', $user->getSetting('guam_enabled')); // max_apssword_age $this->assertSame(null, $user->getConfig()['max_password_age']); $result = $user->setConfig(['max_password_age' => -1]); $this->assertSame([], $result); $this->assertSame(null, $user->getConfig()['max_password_age']); $this->assertSame(null, $user->getSetting('max_password_age')); $result = $user->setConfig(['max_password_age' => 12]); $this->assertSame([], $result); $this->assertSame('12', $user->getConfig()['max_password_age']); $this->assertSame('12', $user->getSetting('max_password_age')); // password_policy $result = $user->setConfig(['password_policy' => true]); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $this->assertSame(null, $user->getConfig()['password_policy']); $this->assertSame(null, $user->getSetting('password_policy')); $result = $user->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $user->setConfig(['password_policy' => 'min:-1']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); $result = $user->setConfig(['password_policy' => 'min:10,unknown']); $this->assertSame(['password_policy' => "Specified password policy is invalid."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $user->setConfig(['password_policy' => 'min:4,max:255']); $this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result); \config(['app.password_policy' => 'min:5,max:100']); $result = $user->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result); \config(['app.password_policy' => 'min:5,max:255']); $result = $user->setConfig(['password_policy' => 'min:10,max:255']); $this->assertSame([], $result); $this->assertSame('min:10,max:255', $user->getConfig()['password_policy']); $this->assertSame('min:10,max:255', $user->getSetting('password_policy')); // limit_geo $this->assertSame([], $user->getConfig()['limit_geo']); $result = $user->setConfig(['limit_geo' => '']); $err = "Specified configuration is invalid. Expected a list of two-letter country codes."; $this->assertSame(['limit_geo' => $err], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => ['usa']]); $this->assertSame(['limit_geo' => $err], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => []]); $this->assertSame([], $result); $this->assertSame(null, $user->getSetting('limit_geo')); $result = $user->setConfig(['limit_geo' => ['US', 'ru']]); $this->assertSame([], $result); $this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']); $this->assertSame('["US","RU"]', $user->getSetting('limit_geo')); } /** * Test user account degradation and un-degradation */ public function testDegradeAndUndegrade(): void { $this->fakeQueueReset(); // Test an account with users, domain $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet = $userA->wallets->first(); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); $this->assertSame(0, $wallet->balance); $this->fakeQueueReset(); // Degrade the account/wallet owner $userA->degrade(); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $this->assertTrue($userA->fresh()->isDegraded()); $this->assertTrue($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertTrue($userB->fresh()->isDegraded(true)); $balance = $wallet->fresh()->balance; $this->assertTrue($balance < 0); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); // Un-Degrade the account/wallet owner $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $yesterday = Carbon::now()->subDays(1); $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); $this->fakeQueueReset(); $userA->undegrade(); $this->assertFalse($userA->fresh()->isDegraded()); $this->assertFalse($userA->fresh()->isDegraded(true)); $this->assertFalse($userB->fresh()->isDegraded()); $this->assertFalse($userB->fresh()->isDegraded(true)); // Expect no balance change, degraded account entitlements are free $this->assertSame($balance, $wallet->fresh()->balance); $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); // Expect one update job for every user // @phpstan-ignore-next-line $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { return TestCase::getObjectProperty($job, 'userId'); })->all(); $this->assertSame([$userA->id, $userB->id], $userIds); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); $this->assertSame(7, $entitlementsA->withTrashed()->count()); $this->assertSame(7, $entitlementsB->withTrashed()->count()); $this->assertSame(7, $entitlementsC->withTrashed()->count()); $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); $this->assertSame(1, $entitlementsResource->withTrashed()->count()); $this->assertSame(1, $entitlementsFolder->withTrashed()->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertSame(0, $transactions->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** * Test eventlog on user deletion */ public function testDeleteAndEventLog(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); EventLog::createFor($user, EventLog::TYPE_SUSPENDED, 'test'); $user->delete(); $this->assertCount(1, EventLog::where('object_id', $user->id)->where('object_type', User::class)->get()); $user->forceDelete(); $this->assertCount(0, EventLog::where('object_id', $user->id)->where('object_type', User::class)->get()); } /** * Test user deletion vs. group membership * * The first Queue::assertPushed is sometimes 1 and sometimes 2 * @group skipci */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Test user deletion vs. rooms */ public function testDeleteWithRooms(): void { $this->markTestIncomplete(); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::hasSku() and countEntitlementsBySku() methods */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); $this->assertSame(0, $john->countEntitlementsBySku('unknown')); $this->assertSame(0, $john->countEntitlementsBySku('2fa')); $this->assertSame(1, $john->countEntitlementsBySku('mailbox')); $this->assertSame(5, $john->countEntitlementsBySku('storage')); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $this->assertSame(0, $resources->count()); } /** * Test sharedFolders() method */ public function testSharedFolders(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folders = $john->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $ned->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $jack->sharedFolders()->get(); $this->assertSame(0, $folders->count()); } /** * Test user restoring */ public function testRestore(): void { $this->fakeQueueReset(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); $this->fakeQueueReset(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertFalse($userA->isActive()); $this->assertTrue($userA->isNew()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); } /** * Test user account restrict() and unrestrict() */ public function testRestrictAndUnrestrict(): void { $this->fakeQueueReset(); // Test an account with users, domain $user = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); $user->assignPackage($package_kolab, $userB); $this->assertFalse($user->isRestricted()); $this->assertFalse($userB->isRestricted()); $user->restrict(); $this->assertTrue($user->fresh()->isRestricted()); $this->assertFalse($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return TestCase::getObjectProperty($job, 'userId') == $user->id; } ); $userB->restrict(); $this->assertTrue($userB->fresh()->isRestricted()); $this->fakeQueueReset(); $user->refresh(); $user->unrestrict(); $this->assertFalse($user->fresh()->isRestricted()); $this->assertTrue($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return TestCase::getObjectProperty($job, 'userId') == $user->id; } ); $this->fakeQueueReset(); $user->unrestrict(true); $this->assertFalse($user->fresh()->isRestricted()); $this->assertFalse($userB->fresh()->isRestricted()); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($userB) { return TestCase::getObjectProperty($job, 'userId') == $userB->id; } ); } /** * Tests for AliasesTrait::setAliases() */ public function testSetAliases(): void { $this->fakeQueueReset(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $this->fakeQueueReset(); $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $this->fakeQueueReset(); $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $this->fakeQueueReset(); $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for suspendAccount() */ public function testSuspendAccount(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $wallet = $user->wallets()->first(); // No entitlements, expect the wallet owner to be suspended anyway $user->suspendAccount(); $this->assertTrue($user->fresh()->isSuspended()); // Add entitlements and more suspendable objects into the wallet $user->unsuspend(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $domain_sku = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $group_sku = Sku::withEnvTenantContext()->where('title', 'group')->first(); $resource_sku = Sku::withEnvTenantContext()->where('title', 'resource')->first(); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userB->assignSku($mailbox_sku, 1, $wallet); $domain = $this->getTestDomain('UserAccount.com', ['type' => \App\Domain::TYPE_PUBLIC]); $domain->assignSku($domain_sku, 1, $wallet); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignSku($group_sku, 1, $wallet); $resource = $this->getTestResource('test-resource@UserAccount.com'); $resource->assignSku($resource_sku, 1, $wallet); $this->assertFalse($user->isSuspended()); $this->assertFalse($userB->isSuspended()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($group->isSuspended()); $this->assertFalse($resource->isSuspended()); $user->suspendAccount(); $this->assertTrue($user->fresh()->isSuspended()); $this->assertTrue($userB->fresh()->isSuspended()); $this->assertTrue($domain->fresh()->isSuspended()); $this->assertTrue($group->fresh()->isSuspended()); $this->assertFalse($resource->fresh()->isSuspended()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { $this->fakeQueueReset(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $this->fakeQueueReset(); $user->setSetting('first_name', 'Firstname1'); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $this->fakeQueueReset(); $user->setSetting('first_name', null); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $this->fakeQueueReset(); $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $this->fakeQueueReset(); $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // Thanks to job locking it creates a single UserUpdate job if (\config('app.with_ldap')) { Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); } // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $expected = [ 'currency' => 'CHF', 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', ]; $this->assertSame($expected, $user->settings()->orderBy('key')->get()->pluck('value', 'key')->all()); $expected = [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ]; $this->assertSame($expected, $user->getSettings(['first_name', 'last_name', 'unknown'])); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } /** * Tests for User::walletOwner() (from EntitleableTrait) */ public function testWalletOwner(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame($john->id, $john->walletOwner()->id); $this->assertSame($john->id, $jack->walletOwner()->id); $this->assertSame($john->id, $ned->walletOwner()->id); // User with no entitlements $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertSame($user->id, $user->walletOwner()->id); } /** * Tests for User::wallets() */ public function testWallets(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $this->assertSame(1, $john->wallets()->count()); $this->assertCount(1, $john->wallets); $this->assertInstanceOf(\App\Wallet::class, $john->wallets->first()); $this->assertSame(1, $ned->wallets()->count()); $this->assertCount(1, $ned->wallets); $this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first()); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 3ea04ebd..bc16230c 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,787 +1,787 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $user->assignSku($sku); }); } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * Assert content of the SKU element in an API response * * @param string $sku_title The SKU title * @param array $result The result to assert * @param array $other Other items the SKU itself does not include */ protected function assertSkuElement($sku_title, $result, $other = []): void { $sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $this->assertCount(8 + count($other), $result); } /** * Set a specific date to existing entitlements */ protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null): void { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update([ 'created_at' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test room whatever it takes. * * @coversNothing */ protected function deleteTestRoom($name) { Queue::fake(); $room = \App\Meet\Room::withTrashed()->where('name', $name)->first(); if (!$room) { return; } $room->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } if (\config('app.with_imap')) { IMAP::deleteUser($user); } if (\config('app.with_ldap')) { LDAP::deleteUser($user); } $user->forceDelete(); } /** * Delete a test companion app whatever it takes. * * @coversNothing */ protected function deleteTestCompanionApp($deviceId) { Queue::fake(); $companionApp = CompanionApp::where('device_id', $deviceId)->first(); if (!$companionApp) { return; } $companionApp->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domainName = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get Room object by name, create it if needed. * * @coversNothing */ protected function getTestRoom($name, $wallet = null, $attrib = [], $config = [], $title = null) { $attrib['name'] = $name; $room = \App\Meet\Room::create($attrib); if ($wallet) { $room->assignToWallet($wallet, $title); } if (!empty($config)) { $room->setConfig($config); } return $room; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domainName = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = [], $createInBackends = false) { // Disable jobs (i.e. skip LDAP oprations) if (!$createInBackends) { Queue::fake(); } $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Get CompanionApp object by deviceId, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestCompanionApp($deviceId, $user, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $companionApp = CompanionApp::firstOrCreate( [ 'device_id' => $deviceId, 'user_id' => $user->id, 'notification_token' => '', 'mfa_enabled' => 1 ], $attrib ); return $companionApp; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } /** * Init fake queue. Release unique job locks. */ protected function fakeQueueReset() { // Release all locks for ShouldBeUnique jobs. Works only with Redis cache. $db = \cache()->getStore()->lockConnection(); $prefix = $db->getOptions()->prefix?->getPrefix(); foreach ($db->keys('*') as $key) { if (strpos($key, 'laravel_unique_job') !== false) { $db->del($prefix ? substr($key, strlen($prefix)) : $key); } } Queue::fake(); } /** * Extract content of an email message. * * @param \Illuminate\Mail\Mailable $mail Mailable object * * @return array Parsed message data: * - 'plain': Plain text body * - 'html: HTML body * - 'subject': Mail subject */ protected function renderMail(\Illuminate\Mail\Mailable $mail): array { $mail->build(); // @phpstan-ignore-line $result = $this->invokeMethod($mail, 'renderForAssertions'); return [ 'plain' => $result[1], 'html' => $result[0], 'subject' => $mail->subject, ]; } /** * Reset a room after tests */ public function resetTestRoom(string $room_name = 'john', $config = []) { $room = \App\Meet\Room::where('name', $room_name)->first(); $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]); if ($room->session_id) { $room->session_id = null; $room->save(); } if (!empty($config)) { $room->setConfig($config); } return $room; } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { - return $a->email > $b->email; + return $a->email <=> $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); Cache::forget('duskconfig'); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } Cache::forget('duskconfig'); parent::tearDown(); } }