diff --git a/bin/quickstart.sh b/bin/quickstart.sh index 9ae8a9f3..c6e713d5 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,83 +1,74 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" base_dir=$(dirname $(dirname $0)) bin/regen-certs docker pull kolab/centos7:latest docker-compose down docker-compose build docker-compose up -d kolab mariadb redis pushd ${base_dir}/src/ cp .env.example .env if [ -f ".env.local" ]; then # Ensure there's a line ending echo "" >> .env cat .env.local >> .env fi -rm -rf vendor/ composer.lock -php -dmemory_limit=-1 /bin/composer install -npm install -find bootstrap/cache/ -type f ! -name ".gitignore" -delete -./artisan key:generate -./artisan jwt:secret -f -./artisan clear-compiled -./artisan cache:clear - if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d worker pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan serve popd diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php index 1f7fc994..d75694fe 100644 --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -1,330 +1,347 @@ [], ]; /** * Class constructor * * @param \App\User $user User object */ public function __construct($user) { $this->user = $user; parent::__construct(); } /** * Validate 2-factor authentication code * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse|null */ public function requestHandler($request) { // get list of configured authentication factors $factors = $this->factors(); // do nothing if no factors configured if (empty($factors)) { return null; } if (empty($request->secondfactor) || !is_string($request->secondfactor)) { $errors = ['secondfactor' => \trans('validation.2fareq')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // try to verify each configured factor foreach ($factors as $factor) { // verify the submitted code // if (strpos($factor, 'dummy:') === 0 && (\app('env') != 'production') { // if ($request->secondfactor === 'dummy') { // return null; // } // } else if ($this->verify($factor, $request->secondfactor)) { return null; } } $errors = ['secondfactor' => \trans('validation.2fainvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } /** * Remove all configured 2FA methods for the current user * * @return bool True on success, False otherwise */ public function removeFactors(): bool { $this->cache = []; $prefs = []; $prefs[$this->key2property('blob')] = null; $prefs[$this->key2property('factors')] = null; return $this->savePrefs($prefs); } /** * Returns a list of 2nd factor methods configured for the user */ public function factors(): array { // First check if the user has the 2FA SKU $sku_2fa = Sku::where('title', '2fa')->first(); if ($sku_2fa) { $has_2fa = $this->user->entitlements()->where('sku_id', $sku_2fa->id)->first(); if ($has_2fa) { $factors = (array) $this->enumerate(); $factors = array_unique($factors); return $factors; } } return []; } /** * Helper method to verify the given method/code tuple * * @param string $factor Factor identifier (:) * @param string $code Authentication code * * @return bool True on successful validation */ protected function verify($factor, $code): bool { $driver = $this->getDriver($factor); return $driver->verify($code, time()); } /** * Load driver class for the given authentication factor * * @param string $factor Factor identifier (:) * * @return \Kolab2FA\Driver\Base */ protected function getDriver(string $factor) { list($method) = explode(':', $factor, 2); $config = \config('2fa.' . $method, []); $driver = \Kolab2FA\Driver\Base::factory($factor, $config); // configure driver $driver->storage = $this; $driver->username = $this->user->email; return $driver; } /** * Helper for seeding a Roundcube account with 2FA setup * for testing. * * @param string $email Email address */ public static function seed(string $email): void { $config = [ 'kolab_2fa_blob' => [ 'totp:8132a46b1f741f88de25f47e' => [ 'label' => 'Mobile app (TOTP)', 'created' => 1584573552, 'secret' => 'UAF477LDHZNWVLNA', 'active' => true, ], // 'dummy:dummy' => [ // 'active' => true, // ], ], 'kolab_2fa_factors' => [ 'totp:8132a46b1f741f88de25f47e', // 'dummy:dummy', ] ]; self::dbh()->table('users')->updateOrInsert( ['username' => $email, 'mail_host' => '127.0.0.1'], ['preferences' => serialize($config)] ); } /** * Helper for generating current TOTP code for a test user * * @param string $email Email address * * @return string Generated code */ public static function code(string $email): string { $sf = new self(User::where('email', $email)->first()); $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e'); return (string) $driver->get_code(); } //****************************************************** // Methods required by Kolab2FA Storage Base //****************************************************** /** * Initialize the storage driver with the given config options */ public function init(array $config) { $this->config = array_merge($this->config, $config); } /** * List methods activated for this user */ public function enumerate() { if ($factors = $this->getFactors()) { return array_keys(array_filter($factors, function ($prop) { return !empty($prop['active']); })); } return []; } /** * Read data for the given key */ public function read($key) { if (!isset($this->cache[$key])) { $factors = $this->getFactors(); $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null; } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { \Log::debug(__METHOD__ . ' ' . @json_encode($value)); // TODO: Not implemented return false; } /** * Remove the data stored for the given key */ public function remove($key) { return $this->write($key, null); } /** * */ protected function getFactors(): array { $prefs = $this->getPrefs(); $key = $this->key2property('blob'); return isset($prefs[$key]) ? (array) $prefs[$key] : []; } /** * */ protected function key2property($key) { // map key to configured property name if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { return $this->config['keymap'][$key]; } // default return 'kolab_2fa_' . $key; } /** * Gets user preferences from Roundcube users table */ protected function getPrefs() { $user = $this->dbh()->table('users') ->select('preferences') ->where('username', strtolower($this->user->email)) ->first(); return $user ? (array) unserialize($user->preferences) : null; } /** * Saves user preferences in Roundcube users table. * This will merge into old preferences */ protected function savePrefs($prefs) { $old_prefs = $this->getPrefs(); if (!is_array($old_prefs)) { return false; } $prefs = array_merge($old_prefs, $prefs); $this->dbh()->table('users') ->where('username', strtolower($this->user->email)) ->update(['preferences' => serialize($prefs)]); return true; } /** * Init connection to the Roundcube database */ public static function dbh() { $dsn = \config('2fa.dsn'); if (empty($dsn)) { \Log::warning("2-FACTOR database not configured"); return DB::connection(\config('database.default')); } \Config::set('database.connections.2fa', ['url' => $dsn]); return DB::connection('2fa'); } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index e5ee16de..ff347a79 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,400 +1,426 @@ 0, 'currency' => 'CHF' ]; + /** + * A wallet can be created with these properties + * + * @var array + */ protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; 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); } } public function chargeEntitlements($apply = true) { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } $charges = 0; $discount = $this->getDiscountRate(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // updated last more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, $entitlementTransactions); } 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; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = ($this->balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @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 $amount, array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, 'amount' => $amount ] ); \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * 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. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * 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') { $amount = round($amount / 100, 2); // Prefer intl extension's number formatter if (class_exists('NumberFormatter')) { $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } return sprintf('%.2f %s', $amount, $this->currency); } /** * 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('App\User', 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany('App\Payment'); } /** * 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); } } /** * Any (additional) properties of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\WalletSetting'); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] ); } } diff --git a/src/tests/Functional/Methods/PackageTest.php b/src/tests/Functional/Methods/PackageTest.php new file mode 100644 index 00000000..69c28a03 --- /dev/null +++ b/src/tests/Functional/Methods/PackageTest.php @@ -0,0 +1,58 @@ +packageDomainHosting = \App\Package::where('title', 'domain-hosting')->first(); + $this->packageKolab = \App\Package::where('title', 'kolab')->first(); + $this->packageLite = \App\Package::where('title', 'lite')->first(); + + $this->skuDomainHosting = \App\Sku::where('title', 'domain-hosting')->first(); + $this->skuGroupware = \App\Sku::where('title', 'groupware')->first(); + $this->skuMailbox = \App\Sku::where('title', 'mailbox')->first(); + $this->skuStorage = \App\Sku::where('title', 'storage')->first(); + } + + public function testSkusDomainHosting() + { + // this is without a package quantity manipulator, just unique SKUs + $this->assertCount(1, $this->packageDomainHosting->skus); + $this->assertTrue($this->packageDomainHosting->skus->contains($this->skuDomainHosting)); + } + + public function testSkusKolab() + { + // this is without a package quantity manipulator, just unique SKUs + $this->assertCount(3, $this->packageKolab->skus); + $this->assertTrue($this->packageKolab->skus->contains($this->skuGroupware)); + $this->assertTrue($this->packageKolab->skus->contains($this->skuMailbox)); + $this->assertTrue($this->packageKolab->skus->contains($this->skuStorage)); + } + + public function testSkusLite() + { + // this is without a package quantity manipulator, just unique SKUs + $this->assertCount(2, $this->packageLite->skus); + } +} diff --git a/src/tests/Unit/Methods/WalletSettingTest.php b/src/tests/Unit/Methods/WalletSettingTest.php deleted file mode 100644 index 469afd28..00000000 --- a/src/tests/Unit/Methods/WalletSettingTest.php +++ /dev/null @@ -1,9 +0,0 @@ -