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'] = '';
$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();
}
}