diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index 195e3e84..92265b0b 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,323 +1,311 @@ commandPrefix == 'scalpel') { return $object; } if (!$this->tenantId) { return $object; } $modelsWithOwner = [ \App\Wallet::class, ]; // Add tenant filter if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($object::class))) { $context = new \App\User(); $context->tenant_id = $this->tenantId; $object = $object->withObjectTenantContext($context); } elseif (in_array($object::class, $modelsWithOwner)) { $object = $object->whereExists(function ($query) { $query->select(DB::raw(1)) ->from('users') ->whereRaw('wallets.user_id = users.id') ->whereRaw("users.tenant_id = {$this->tenantId}"); }); } return $object; } /** * Shortcut to creating a progress bar of a particular format with a particular message. * * @param int $count Number of progress steps * @param string $message The description * * @return \Symfony\Component\Console\Helper\ProgressBar */ protected function createProgressBar($count, $message = null) { $bar = $this->output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage("{$message}..."); } $bar->start(); return $bar; } /** * Find the domain. * * @param string $domain Domain ID or namespace * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ public function getDomain($domain, $withDeleted = false) { return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find a group. * * @param string $group Group ID or email * @param bool $withDeleted Include deleted * * @return \App\Group|null */ public function getGroup($group, $withDeleted = false) { return $this->getObject(\App\Group::class, $group, 'email', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { if (!$withDeleted) { // @phpstan-ignore-next-line $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); if (!$object && !empty($objectTitle)) { $object = $this->getObjectModel($objectClass, $withDeleted) ->where($objectTitle, $objectIdOrTitle)->first(); } if (!$this->tenantId && $object && !empty($object->tenant_id)) { $this->tenantId = $object->tenant_id; } return $object; } /** * Returns a preconfigured Model object for a specified class. * * @param string $objectClass The name of the class * @param bool $withDeleted Include withTrashed() query * * @return mixed */ protected function getObjectModel($objectClass, $withDeleted = false) { if ($withDeleted) { $model = $objectClass::withTrashed(); } else { $model = new $objectClass(); } return $this->applyTenant($model); } /** * Find a resource. * * @param string $resource Resource ID or email * @param bool $withDeleted Include deleted * * @return \App\Resource|null */ public function getResource($resource, $withDeleted = false) { return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); } /** * Find a shared folder. * * @param string $folder Folder ID or email * @param bool $withDeleted Include deleted * * @return \App\SharedFolder|null */ public function getSharedFolder($folder, $withDeleted = false) { return $this->getObject(\App\SharedFolder::class, $folder, 'email', $withDeleted); } /** * Find the user. * * @param string $user User ID or email * @param bool $withDeleted Include deleted * * @return \App\User|null */ public function getUser($user, $withDeleted = false) { return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet); } /** * Execute the console command. * * @return mixed */ public function handle() { if ($this->dangerous) { $this->warn( "This command is a dangerous scalpel command with potentially significant unintended consequences" ); $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); if (!$confirmation) { $this->info("Better safe than sorry."); exit(0); } $this->info("Vámonos!"); } // @phpstan-ignore-next-line if ($this->withTenant && $this->hasOption('tenant') && ($tenantId = $this->option('tenant'))) { $tenant = $this->getObject(\App\Tenant::class, $tenantId, 'title'); if (!$tenant) { $this->error("Tenant {$tenantId} not found"); return 1; } $this->tenantId = $tenant->id; } else { $this->tenantId = \config('app.tenant_id'); } } - /** - * Checks that a model is soft-deletable - * - * @param string $class Model class name - * - * @return bool - */ - protected function isSoftDeletable($class) - { - return class_exists($class) && method_exists($class, 'forceDelete'); - } - /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; // @phpstan-ignore-next-line foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/ObjectDeleteCommand.php b/src/app/Console/ObjectDeleteCommand.php index 52a8b0a6..8b90df95 100644 --- a/src/app/Console/ObjectDeleteCommand.php +++ b/src/app/Console/ObjectDeleteCommand.php @@ -1,59 +1,59 @@ description = "Delete a {$this->objectName}"; $this->signature = sprintf( "%s%s:delete {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectName ); - if ($this->isSoftDeletable($this->objectClass)) { + if (\App\Utils::isSoftDeletable($this->objectClass)) { $this->signature .= " {--with-deleted : Consider deleted {$this->objectName}s}"; } parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { parent::handle(); $argument = $this->argument($this->objectName); $object = $this->getObject($this->objectClass, $argument, $this->objectTitle); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } if ($this->commandPrefix == 'scalpel') { if ($object->deleted_at) { $object->forceDeleteQuietly(); } else { $object->deleteQuietly(); } } else { if ($object->deleted_at) { $object->forceDelete(); } else { $object->delete(); } } } } diff --git a/src/app/Console/ObjectListCommand.php b/src/app/Console/ObjectListCommand.php index f88010d0..82f2345a 100644 --- a/src/app/Console/ObjectListCommand.php +++ b/src/app/Console/ObjectListCommand.php @@ -1,117 +1,118 @@ 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"; } if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($this->objectClass))) { $this->signature .= " {--tenant= : Limit results to the specified tenant}"; } - if ($this->isSoftDeletable($this->objectClass)) { + if (\App\Utils::isSoftDeletable($this->objectClass)) { $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() { - if ($this->isSoftDeletable($this->objectClass) && $this->option('with-deleted')) { + // @phpstan-ignore-next-line + if (\App\Utils::isSoftDeletable($this->objectClass) && $this->option('with-deleted')) { $objects = $this->objectClass::withTrashed(); } else { $objects = new $this->objectClass(); } // @phpstan-ignore-next-line if ($this->hasOption('tenant') && ($tenant = intval($this->option('tenant')))) { $this->tenantId = $tenant; $objects = $this->applyTenant($objects); } 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 = (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/Console/ObjectRelationListCommand.php b/src/app/Console/ObjectRelationListCommand.php index abf6cd49..d1dc3c08 100644 --- a/src/app/Console/ObjectRelationListCommand.php +++ b/src/app/Console/ObjectRelationListCommand.php @@ -1,121 +1,121 @@ description = "List {$this->objectRelation} for a {$this->objectName}"; $this->signature = sprintf( "%s%s:%s {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, Str::kebab($this->objectRelation), $this->objectName ); if (empty($this->objectRelationClass)) { $this->objectRelationClass = "App\\" . rtrim(ucfirst($this->objectRelation), 's'); } - if ($this->isSoftDeletable($this->objectRelationClass)) { + if (\App\Utils::isSoftDeletable($this->objectRelationClass)) { $this->signature .= " {--with-deleted : Include deleted objects}"; } $this->signature .= " {--attr=* : Attributes other than the primary unique key to include}"; parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { $argument = $this->argument($this->objectName); $object = $this->getObject( $this->objectClass, $argument, $this->objectTitle, true ); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } if (method_exists($object, $this->objectRelation)) { $result = call_user_func_array([$object, $this->objectRelation], $this->objectRelationArgs); } elseif (property_exists($object, $this->objectRelation)) { $result = $object->{"{$this->objectRelation}"}; } else { $this->error("No such relation {$this->objectRelation}"); return 1; } // Convert query builder into a collection if ( ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) || ($result instanceof \Illuminate\Database\Eloquent\Builder) ) { // @phpstan-ignore-next-line - if ($this->isSoftDeletable($this->objectRelationClass) && $this->option('with-deleted')) { + if (\App\Utils::isSoftDeletable($this->objectRelationClass) && $this->option('with-deleted')) { $result->withoutGlobalScope(SoftDeletingScope::class); } $result = $result->get(); } // Print the result if ( ($result instanceof \Illuminate\Database\Eloquent\Collection) || is_array($result) ) { foreach ($result as $entry) { $this->info($this->toString($entry)); } } else { $this->info($this->toString($result)); } } } diff --git a/src/app/Traits/UuidStrKeyTrait.php b/src/app/Traits/UuidStrKeyTrait.php index 7c01c695..704b5dd2 100644 --- a/src/app/Traits/UuidStrKeyTrait.php +++ b/src/app/Traits/UuidStrKeyTrait.php @@ -1,51 +1,50 @@ {$model->getKeyName()})) { $allegedly_unique = \App\Utils::uuidStr(); // Verify if unique - if (in_array('Illuminate\Database\Eloquent\SoftDeletes', class_uses($model))) { - while ($model->withTrashed()->find($allegedly_unique)) { - $allegedly_unique = \App\Utils::uuidStr(); - } - } else { - while ($model->find($allegedly_unique)) { - $allegedly_unique = \App\Utils::uuidStr(); - } + $finder = $model; + if (\App\Utils::isSoftDeletable($model)) { + $finder = $finder->withTrashed(); + } + + while ($finder->find($allegedly_unique)) { + $allegedly_unique = \App\Utils::uuidStr(); } $model->{$model->getKeyName()} = $allegedly_unique; } }); } /** * Get if the key is incrementing. * * @return bool */ public function getIncrementing() { return false; } /** * Get the key type. * * @return string */ public function getKeyType() { return 'string'; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index e39417af..178a36d6 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,617 +1,631 @@ 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; } /** * Default route handler */ public static function defaultView() { // Return 404 for requests to the API end-points that do not exist if (strpos(request()->path(), 'api/') === 0) { return \App\Http\Controllers\Controller::errorResponse(404); } $env = self::uiEnv(); return view($env['view'])->with('env', $env); } /** * 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') { 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; } + /** + * Checks that a model is soft-deletable + * + * @param mixed $model Model object or a class name + */ + public static function isSoftDeletable($model): bool + { + if (is_string($model) && !class_exists($model)) { + return false; + } + + return method_exists($model, 'restore'); + } + /** * 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++) { $string = []; for ($y = 0; $y < $length; $y++) { $string[] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($string); $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/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index c8adf3b1..c4a0840a 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,234 +1,246 @@ delete(); \App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete(); $this->assertSame('', Utils::countryForIP('127.0.0.1', '')); $this->assertSame('CH', Utils::countryForIP('127.0.0.1')); $this->assertSame('', Utils::countryForIP('2001:db8::ff00:42:1', '')); $this->assertSame('CH', Utils::countryForIP('2001:db8::ff00:42:1')); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); \App\IP6Net::create([ 'net_number' => '2001:db8::ff00:42:0', 'net_broadcast' => \App\Utils::ip6Broadcast('2001:db8::ff00:42:0', 8), 'net_mask' => 8, 'country' => 'PL', 'rir_name' => 'test', 'serial' => 1, ]); $this->assertSame('US', Utils::countryForIP('127.0.0.1', '')); $this->assertSame('US', Utils::countryForIP('127.0.0.1')); $this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1', '')); $this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1')); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); \App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete(); } /** * Test for Utils::emailToLower() */ public function testEmailToLower(): void { $this->assertSame('test@test.tld', Utils::emailToLower('test@Test.Tld')); $this->assertSame('test@test.tld', Utils::emailToLower('Test@Test.Tld')); $this->assertSame('shared+shared/Test@test.tld', Utils::emailToLower('shared+shared/Test@Test.Tld')); } /** * Test for Utils::ensureAclPostPermission() */ public function testEnsureAclPostPermission(): void { $acl = []; $this->assertSame(['anyone, p'], Utils::ensureAclPostPermission($acl)); $acl = ['anyone, full']; $this->assertSame(['anyone, full'], Utils::ensureAclPostPermission($acl)); $acl = ['anyone, read-only']; $this->assertSame(['anyone, lrsp'], Utils::ensureAclPostPermission($acl)); $acl = ['anyone, read-write']; $this->assertSame(['anyone, lrswitednp'], Utils::ensureAclPostPermission($acl)); } + /** + * Test for Utils::isSoftDeletable() + */ + public function testIsSoftDeletable(): void + { + $this->assertTrue(Utils::isSoftDeletable(\App\User::class)); + $this->assertFalse(Utils::isSoftDeletable(\App\Wallet::class)); + + $this->assertTrue(Utils::isSoftDeletable(new \App\User())); + $this->assertFalse(Utils::isSoftDeletable(new \App\Wallet())); + } + /** * Test for Utils::money() */ public function testMoney(): void { $this->assertSame('-0,01 CHF', Utils::money(-1, 'CHF')); $this->assertSame('0,00 CHF', Utils::money(0, 'CHF')); $this->assertSame('1,11 €', Utils::money(111, 'EUR')); $this->assertSame('1,00 CHF', Utils::money(100, 'CHF')); $this->assertSame('€0.00', Utils::money(0, 'EUR', 'en_US')); } /** * Test for Utils::percent() */ public function testPercent(): void { $this->assertSame('0 %', Utils::percent(0)); $this->assertSame('10 %', Utils::percent(10.0)); $this->assertSame('10,12 %', Utils::percent(10.12)); } /** * Test for Utils::normalizeAddress() */ public function testNormalizeAddress(): void { $this->assertSame('', Utils::normalizeAddress('')); $this->assertSame('', Utils::normalizeAddress(null)); $this->assertSame('test', Utils::normalizeAddress('TEST')); $this->assertSame('test@domain.tld', Utils::normalizeAddress('Test@Domain.TLD')); $this->assertSame('test@domain.tld', Utils::normalizeAddress('Test+Trash@Domain.TLD')); $this->assertSame(['', ''], Utils::normalizeAddress('', true)); $this->assertSame(['', ''], Utils::normalizeAddress(null, true)); $this->assertSame(['test', ''], Utils::normalizeAddress('TEST', true)); $this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test@Domain.TLD', true)); $this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test+Trash@Domain.TLD', true)); } /** * Test for Tests\Utils::powerSet() */ public function testPowerSet(): void { $set = []; $result = \Tests\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(0, $result); $set = ["a1"]; $result = \Tests\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertTrue(in_array(["a1"], $result)); $set = ["a1", "a2"]; $result = \Tests\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(3, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $set = ["a1", "a2", "a3"]; $result = \Tests\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(7, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a3"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $this->assertTrue(in_array(["a1", "a3"], $result)); $this->assertTrue(in_array(["a2", "a3"], $result)); $this->assertTrue(in_array(["a1", "a2", "a3"], $result)); } /** * Test for Utils::serviceUrl() */ public function testServiceUrl(): void { $public_href = 'https://public.url/cockpit'; $local_href = 'https://local.url/cockpit'; \config([ 'app.url' => $local_href, 'app.public_url' => '', ]); $this->assertSame($local_href, Utils::serviceUrl('')); $this->assertSame($local_href . '/unknown', Utils::serviceUrl('unknown')); $this->assertSame($local_href . '/unknown', Utils::serviceUrl('/unknown')); \config([ 'app.url' => $local_href, 'app.public_url' => $public_href, ]); $this->assertSame($public_href, Utils::serviceUrl('')); $this->assertSame($public_href . '/unknown', Utils::serviceUrl('unknown')); $this->assertSame($public_href . '/unknown', Utils::serviceUrl('/unknown')); } /** * Test for Utils::uuidInt() */ public function testUuidInt(): void { $result = Utils::uuidInt(); $this->assertTrue(is_int($result)); $this->assertTrue($result > 0); } /** * Test for Utils::uuidStr() */ public function testUuidStr(): void { $result = Utils::uuidStr(); $this->assertTrue(is_string($result)); $this->assertTrue(strlen($result) === 36); $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); } /** * Test for Utils::exchangeRate() */ public function testExchangeRate(): void { $this->assertSame(1.0, Utils::exchangeRate("DUMMY", "dummy")); // Exchange rates are volatile, can't test with high accuracy. $this->assertTrue(Utils::exchangeRate("CHF", "EUR") >= 0.88); //$this->assertEqualsWithDelta(0.90503424978382, Utils::exchangeRate("CHF", "EUR"), PHP_FLOAT_EPSILON); $this->assertTrue(Utils::exchangeRate("EUR", "CHF") <= 1.12); //$this->assertEqualsWithDelta(1.1049305595217682, Utils::exchangeRate("EUR", "CHF"), PHP_FLOAT_EPSILON); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("CHF", "FOO")); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("FOO", "CHF")); } }