diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index 06b7962a..e75c1b03 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,128 +1,128 @@ errorResponse(404); } /** * Search for domains * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map( function ($domain) { return $this->objectToClient($domain); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->suspend(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.domain-suspend-success'), + 'message' => self::trans('app.domain-suspend-success'), ]); } /** * Un-Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->unsuspend(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.domain-unsuspend-success'), + 'message' => self::trans('app.domain-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php index 11051e5f..29752cdc 100644 --- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php @@ -1,107 +1,107 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->groups(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($group = Group::where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map( function ($group) { return $this->objectToClient($group); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new group. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $group->suspend(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.distlist-suspend-success'), + 'message' => self::trans('app.distlist-suspend-success'), ]); } /** * Un-Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $group->unsuspend(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.distlist-unsuspend-success'), + 'message' => self::trans('app.distlist-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php index 481d7b7f..0b32be40 100644 --- a/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php @@ -1,59 +1,59 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->resources(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($resource = Resource::where('email', $search)->first()) { $result->push($resource); } } // Process the result $result = $result->map( function ($resource) { return $this->objectToClient($resource); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxresources', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxresources', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new resource. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php index 2bf4e2a2..4021ff23 100644 --- a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php @@ -1,59 +1,59 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->sharedFolders(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($folder = SharedFolder::where('email', $search)->first()) { $result->push($folder); } } // Process the result $result = $result->map( function ($folder) { return $this->objectToClient($folder); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxshared-folders', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxshared-folders', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new shared folder. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php index c64dc6e5..db230951 100644 --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -1,514 +1,514 @@ errorResponse(404); } $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } $result = $this->{$method}(); return response()->json($result); } /** * Get discounts chart */ protected function chartDiscounts(): array { $discounts = DB::table('wallets') ->selectRaw("discount, count(discount_id) as cnt") ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') ->groupBy('discounts.discount'); $addTenantScope = function ($builder, $tenantId) { return $builder->where('users.tenant_id', $tenantId); }; $discounts = $this->applyTenantScope($discounts, $addTenantScope) ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); // $labels = [10, 25, 30, 100]; // $discounts = [100, 120, 30, 50]; $labels = array_map(function ($item) { return $item . '%'; }, $labels); - return $this->donutChart(\trans('app.chart-discounts'), $labels, $discounts); + return $this->donutChart(self::trans('app.chart-discounts'), $labels, $discounts); } /** * Get income chart */ protected function chartIncome(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); // FIXME: We're using wallets.currency instead of payments.currency and payments.currency_amount // as I believe this way we have more precise amounts for this use-case (and default currency) $query = DB::table('payments') ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(credit_amount) as amount, wallets.currency") ->join('wallets', 'wallets.id', '=', 'wallet_id') ->where('updated_at', '>=', $start->toDateString()) ->where('status', Payment::STATUS_PAID) ->whereIn('type', [Payment::TYPE_ONEOFF, Payment::TYPE_RECURRING]) ->groupByRaw('period, wallets.currency'); $addTenantScope = function ($builder, $tenantId) { $where = sprintf( '`wallets`.`user_id` IN (select `id` from `users` where `tenant_id` = %d)', $tenantId ); return $builder->whereRaw($where); }; $currency = $this->currency(); $payments = []; $this->applyTenantScope($query, $addTenantScope) ->get() ->each(function ($record) use (&$payments, $currency) { $amount = $record->amount; if ($record->currency != $currency) { $amount = intval(round($amount * \App\Utils::exchangeRate($record->currency, $currency))); } if (isset($payments[$record->period])) { $payments[$record->period] += $amount / 100; } else { $payments[$record->period] = $amount / 100; } }); // TODO: exclude refunds/chargebacks $empty = array_fill_keys($labels, 0); $payments = array_values(array_merge($empty, $payments)); // $payments = [1000, 1200.25, 3000, 1897.50, 2000, 1900, 2134, 3330]; $avg = collect($payments)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ - 'title' => \trans('app.chart-income', ['currency' => $currency]), + 'title' => self::trans('app.chart-income', ['currency' => $currency]), 'type' => 'bar', 'colors' => [self::COLOR_BLUE], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Payments', 'values' => $payments ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.2f', $avg), 'value' => $avg, 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get payers chart */ protected function chartPayers(): array { list($labels, $stats) = $this->getCollectedStats(self::TYPE_PAYERS, 54, fn($v) => intval($v)); // See https://frappe.io/charts/docs for format/options description return [ - 'title' => \trans('app.chart-payers'), + 'title' => self::trans('app.chart-payers'), 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $stats ] ] ] ]; } /** * Get created/deleted users chart */ protected function chartUsers(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) ->groupByRaw('1'); $created = $this->applyTenantScope($created)->get(); $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); $deleted = array_values(array_merge($empty, $deleted->pluck('cnt', 'period')->all())); // $created = [5, 2, 4, 2, 0, 5, 2, 4]; // $deleted = [1, 2, 3, 1, 2, 1, 2, 3]; $avg = collect($created)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ - 'title' => \trans('app.chart-users'), + 'title' => self::trans('app.chart-users'), 'type' => 'bar', // Required to fix https://github.com/frappe/charts/issues/294 'colors' => [self::COLOR_GREEN, self::COLOR_RED], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ - 'name' => \trans('app.chart-created'), + 'name' => self::trans('app.chart-created'), 'chartType' => 'bar', 'values' => $created ], [ - 'name' => \trans('app.chart-deleted'), + 'name' => self::trans('app.chart-deleted'), 'chartType' => 'line', 'values' => $deleted ] ], 'yMarkers' => [ [ - 'label' => sprintf('%s = %.1f', \trans('app.chart-average'), $avg), + 'label' => sprintf('%s = %.1f', self::trans('app.chart-average'), $avg), 'value' => collect($created)->avg(), 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get all users chart */ protected function chartUsersAll(): array { $weeks = 54; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) ->groupByRaw('1'); $created = $this->applyTenantScope($created)->get(); $deleted = $this->applyTenantScope($deleted)->get(); $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); $deleted = array_merge($empty, $deleted->pluck('cnt', 'period')->all()); $all = []; foreach (array_reverse($labels) as $label) { $all[] = $count; $count -= $created[$label] - $deleted[$label]; } $all = array_reverse($all); // $start = 3000; // for ($i = 0; $i < count($labels); $i++) { // $all[$i] = $start + $i * 15; // } // See https://frappe.io/charts/docs for format/options description return [ - 'title' => \trans('app.chart-allusers'), + 'title' => self::trans('app.chart-allusers'), 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $all ] ] ] ]; } /** * Get vouchers chart */ protected function chartVouchers(): array { $vouchers = DB::table('wallets') ->selectRaw("count(discount_id) as cnt, code") ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNotNull('code') ->whereNull('users.deleted_at') ->groupBy('discounts.code') ->havingRaw("count(discount_id) > 0") ->orderByRaw('1'); $addTenantScope = function ($builder, $tenantId) { return $builder->where('users.tenant_id', $tenantId); }; $vouchers = $this->applyTenantScope($vouchers, $addTenantScope) ->pluck('cnt', 'code')->all(); $labels = array_keys($vouchers); $vouchers = array_values($vouchers); // $labels = ["TEST", "NEW", "OTHER", "US"]; // $vouchers = [100, 120, 30, 50]; - return $this->donutChart(\trans('app.chart-vouchers'), $labels, $vouchers); + return $this->donutChart(self::trans('app.chart-vouchers'), $labels, $vouchers); } protected static function donutChart($title, $labels, $data): array { // See https://frappe.io/charts/docs for format/options description return [ 'title' => $title, 'type' => 'donut', 'colors' => [ self::COLOR_BLUE, self::COLOR_BLUE_DARK, self::COLOR_GREEN, self::COLOR_GREEN_DARK, self::COLOR_ORANGE, self::COLOR_RED, self::COLOR_RED_DARK ], 'maxSlices' => 8, 'tooltipOptions' => [], // does not work without it (https://github.com/frappe/charts/issues/314) 'data' => [ 'labels' => $labels, 'datasets' => [ [ 'values' => $data ] ] ] ]; } /** * Add tenant scope to the queries when needed * * @param \Illuminate\Database\Query\Builder $query The query * @param callable $addQuery Additional tenant-scope query-modifier * * @return \Illuminate\Database\Query\Builder */ protected function applyTenantScope($query, $addQuery = null) { // TODO: Per-tenant stats for admins return $query; } /** * Get the currency for stats * * @return string Currency code */ protected function currency() { $user = $this->guard()->user(); // For resellers return their wallet currency if ($user->role == 'reseller') { $currency = $user->wallet()->currency; } // System currency for others return \config('app.currency'); } /** * Get collected stats for a specific type/period * * @param int $type Chart * @param int $weeks Number of weeks back from now * @param ?callable $itemCallback A callback to execute on every stat item * * @return array [ labels, stats ] */ protected function getCollectedStats(int $type, int $weeks, $itemCallback = null): array { $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); // Get the stats grouped by tenant and week $stats = DB::table('stats') ->selectRaw("tenant_id, date_format(created_at, '%Y-%v') as period, avg(value) as cnt") ->where('type', $type) ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1,2'); // Get the query result and sum up per-tenant stats $result = []; $this->applyTenantScope($stats)->get() ->each(function ($item) use (&$result) { $result[$item->period] = ($result[$item->period] ?? 0) + $item->cnt; }); // Process the result, e.g. convert values to int if ($itemCallback) { $result = array_map($itemCallback, $result); } // Fill the missing weeks with zeros $result = array_values(array_merge(array_fill_keys($labels, 0), $result)); return [$labels, $result]; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 7381a0d9..29a59615 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,376 +1,376 @@ errorResponse(404); } /** * Searching of user accounts. * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { $owner = User::find($owner); if ($owner) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) ->orderBy('email') ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = \App\UserSetting::where('key', 'external_email') ->where('value', $search) ->get() ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by an email of a group, resource, shared folder, etc. if ($group = \App\Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } elseif ($alias = \App\SharedFolderAlias::where('alias', $search)->first()) { $user_ids = $user_ids->merge([$alias->sharedFolder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) ->orderBy('email') ->get(); } } } elseif (is_numeric($search)) { // Search by user ID $user = User::withTrashed()->where('id', $search) ->first(); if ($user) { $result->push($user); } } elseif (strpos($search, '.') !== false) { // Search by domain $domain = Domain::withTrashed()->where('namespace', $search) ->first(); if ($domain) { if (($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->first())) { $result->push($owner); } } // A mollie customer ID } elseif (substr($search, 0, 4) == 'cst_') { $setting = \App\WalletSetting::where( [ 'key' => 'mollie_id', 'value' => $search ] )->first(); if ($setting) { if ($wallet = $setting->wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // A mollie transaction ID } elseif (substr($search, 0, 3) == 'tr_') { $payment = \App\Payment::find($search); if ($payment) { if ($owner = $payment->wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } elseif (!empty($search)) { $wallet = Wallet::find($search); if ($wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user, true); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-reset-2fa-success'), + 'message' => self::trans('app.user-reset-2fa-success'), ]); } /** * Reset Geo-Lockin for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function resetGeoLock(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->setConfig(['limit_geo' => []]); return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-reset-geo-lock-success'), + 'message' => self::trans('app.user-reset-geo-lock-success'), ]); } /** * Set/Add a SKU for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * @param string $sku SKU title * * @return \Illuminate\Http\JsonResponse The response */ public function setSku(Request $request, $id, $sku) { // For now we allow adding the 'beta' SKU only if ($sku != 'beta') { return $this->errorResponse(404); } $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', $sku)->first(); if (!$sku) { return $this->errorResponse(404); } if ($user->entitlements()->where('sku_id', $sku->id)->first()) { - return $this->errorResponse(422, \trans('app.user-set-sku-already-exists')); + return $this->errorResponse(422, self::trans('app.user-set-sku-already-exists')); } $user->assignSku($sku); /** @var \App\Entitlement $entitlement */ $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-set-sku-success'), + 'message' => self::trans('app.user-set-sku-success'), 'sku' => [ 'cost' => $entitlement->cost, 'name' => $sku->name, 'id' => $sku->id, ] ]); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->suspend(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-suspend-success'), + 'message' => self::trans('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->unsuspend(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-unsuspend-success'), + 'message' => self::trans('app.user-unsuspend-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-update-success'), + 'message' => self::trans('app.user-update-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php index 423c2e20..a9843bc6 100644 --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -1,146 +1,146 @@ checkTenant($wallet->owner)) { return $this->errorResponse(404); } $result = $wallet->toArray(); $result['discount'] = 0; $result['discount_description'] = ''; if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } $result['mandate'] = PaymentsController::walletMandate($wallet); $provider = PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['providerLink'] = $provider->customerLink($wallet); $result['notice'] = $this->getWalletNotice($wallet); // for resellers return response()->json($result); } /** * Award/penalize a wallet. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); $user = $this->guard()->user(); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'amount' => 'required|numeric', 'description' => 'required|string|max:1024', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); $method = $amount > 0 ? 'award' : 'penalty'; DB::beginTransaction(); $wallet->{$method}(abs($amount), $request->description); if ($user->role == 'reseller') { if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) { $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}"; $tenant_method = $amount > 0 ? 'debit' : 'credit'; $tenant_wallet->{$tenant_method}(abs($amount), $desc); } } DB::commit(); $response = [ 'status' => 'success', - 'message' => \trans("app.wallet-{$method}-success"), + 'message' => self::trans("app.wallet-{$method}-success"), 'balance' => $wallet->balance ]; return response()->json($response); } /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } if (array_key_exists('discount', $request->input())) { if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); } elseif ($discount = Discount::withObjectTenantContext($wallet->owner)->find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } } $response = $wallet->toArray(); if ($wallet->discount) { $response['discount'] = $wallet->discount->discount; $response['discount_description'] = $wallet->discount->description; } $response['status'] = 'success'; - $response['message'] = \trans('app.wallet-update-success'); + $response['message'] = self::trans('app.wallet-update-success'); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php index 4bfa2971..7fc3f190 100644 --- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -1,269 +1,269 @@ errorResponse(404); } $user = $this->guard()->user(); if ($user->id != $companion->user_id) { return $this->errorResponse(403); } // Revoke client and tokens $client = $companion->passportClient(); if ($client) { $clientRepository = app(ClientRepository::class); $clientRepository->delete($client); } $companion->delete(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.companion-delete-success'), + 'message' => self::trans('app.companion-delete-success'), ]); } /** * Create a companion app. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { $user = $this->guard()->user(); $v = Validator::make( $request->all(), [ 'name' => 'required|string|max:512', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $app = \App\CompanionApp::create([ 'name' => $request->name, 'user_id' => $user->id, ]); return response()->json([ 'status' => 'success', - 'message' => \trans('app.companion-create-success'), + 'message' => self::trans('app.companion-create-success'), 'id' => $app->id ]); } /** * Register a companion app. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function register(Request $request) { $user = $this->guard()->user(); $v = Validator::make( $request->all(), [ 'notificationToken' => 'required|string|min:4|max:512', 'deviceId' => 'required|string|min:4|max:64', 'companionId' => 'required|max:64', 'name' => 'required|string|max:512', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $notificationToken = $request->notificationToken; $deviceId = $request->deviceId; $companionId = $request->companionId; $name = $request->name; \Log::info("Registering app. Notification token: {$notificationToken} Device id: {$deviceId} Name: {$name}"); $app = \App\CompanionApp::find($companionId); if (!$app) { return $this->errorResponse(404); } if ($app->user_id != $user->id) { \Log::warning("User mismatch on device registration. Expected {$user->id} but found {$app->user_id}"); return $this->errorResponse(403); } $app->device_id = $deviceId; $app->mfa_enabled = true; $app->name = $name; $app->notification_token = $notificationToken; $app->save(); return response()->json(['status' => 'success']); } /** * Generate a QR-code image for a string * * @param string $data data to encode * * @return string */ private static function generateQRCode($data) { $renderer_style = new BaconQrCode\Renderer\RendererStyle\RendererStyle(300, 1); $renderer_image = new BaconQrCode\Renderer\Image\SvgImageBackEnd(); $renderer = new BaconQrCode\Renderer\ImageRenderer($renderer_style, $renderer_image); $writer = new BaconQrCode\Writer($renderer); return 'data:image/svg+xml;base64,' . base64_encode($writer->writeString($data)); } /** * List devices. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = \App\CompanionApp::where('user_id', $user->id); $result = $result->orderBy('created_at') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($device) { return array_merge($device->toArray(), [ 'isReady' => $device->isPaired() ]); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Get the information about the specified companion app. * * @param string $id CompanionApp identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $result = \App\CompanionApp::find($id); if (!$result) { return $this->errorResponse(404); } $user = $this->guard()->user(); if ($user->id != $result->user_id) { return $this->errorResponse(403); } return response()->json(array_merge($result->toArray(), [ 'statusInfo' => [ 'isReady' => $result->isPaired() ] ])); } /** * Retrieve the pairing information encoded into a qrcode image. * * @return \Illuminate\Http\JsonResponse */ public function pairing($id) { $result = \App\CompanionApp::find($id); if (!$result) { return $this->errorResponse(404); } $user = $this->guard()->user(); if ($user->id != $result->user_id) { return $this->errorResponse(403); } $client = $result->passportClient(); if (!$client) { $client = Passport::client()->forceFill([ 'user_id' => $user->id, 'name' => "CompanionApp Password Grant Client", 'secret' => Str::random(40), 'provider' => 'users', 'redirect' => 'https://' . \config('app.website_domain'), 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, 'allowed_scopes' => "mfa" ]); $client->save(); $result->setPassportClient($client); $result->save(); } $response['qrcode'] = self::generateQRCode( json_encode([ "serverUrl" => Utils::serviceUrl('', $user->tenant_id), "clientIdentifier" => $client->id, "clientSecret" => $client->secret, "companionId" => $id, "username" => $user->email ]) ); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index 1d3e7c31..6b100647 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,329 +1,329 @@ checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', - 'message' => \trans('app.domain-verify-error'), + 'message' => self::trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), - 'message' => \trans('app.domain-verify-success'), + 'message' => self::trans('app.domain-verify-success'), ]); } /** * Remove the specified domain. * * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { $domain = Domain::withEnvTenantContext()->find($id); if (empty($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($domain)) { return $this->errorResponse(403); } // It is possible to delete domain only if there are no users/aliases/groups using it. if (!$domain->isEmpty()) { - $response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')]; + $response = ['status' => 'error', 'message' => self::trans('app.domain-notempty-error')]; return response()->json($response, 422); } $domain->delete(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.domain-delete-success'), + 'message' => self::trans('app.domain-delete-success'), ]); } /** * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } // Validate the input $v = Validator::make( $request->all(), [ 'namespace' => ['required', 'string', new UserEmailDomain()] ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $namespace = \strtolower(request()->input('namespace')); // Domain already exists if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) { // Check if the domain is soft-deleted and belongs to the same user $deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet()) && $wallet->owner && $wallet->owner->id == $owner->id; if (!$deleteBeforeCreate) { - $errors = ['namespace' => \trans('validation.domainnotavailable')]; + $errors = ['namespace' => self::trans('validation.domainnotavailable')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { - $errors = ['package' => \trans('validation.packagerequired')]; + $errors = ['package' => self::trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if (!$package->isDomain()) { - $errors = ['package' => \trans('validation.packageinvalid')]; + $errors = ['package' => self::trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Force-delete the existing domain if it is soft-deleted and belongs to the same user if (!empty($deleteBeforeCreate)) { $domain->forceDelete(); } // Create the domain $domain = Domain::create([ 'namespace' => $namespace, 'type' => \App\Domain::TYPE_EXTERNAL, ]); $domain->assignPackage($package, $owner); DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.domain-create-success'), + 'message' => self::trans('app.domain-create-success'), ]); } /** * Get the information about the specified domain. * * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $this->objectToClient($domain, true); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['mx'] = self::getMXConfig($domain->namespace); // Domain configuration, e.g. spf whitelist $response['config'] = $domain->getConfig(); // Status info $response['statusInfo'] = self::statusInfo($domain); // Entitlements/Wallet info SkusController::objectEntitlements($domain, $response); return response()->json($response); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo($domain): array { // If that is not a public domain, add domain specific steps return self::processStateInfo( $domain, [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"], ] ); } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Domain $domain, string $step): ?bool { try { switch ($step) { case 'domain-ldap-ready': // Use worker to do the job \App\Jobs\Domain\CreateJob::dispatch($domain->id); return null; case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/FilesController.php b/src/app/Http/Controllers/API/V4/FilesController.php index eef2aaec..a146eeb2 100644 --- a/src/app/Http/Controllers/API/V4/FilesController.php +++ b/src/app/Http/Controllers/API/V4/FilesController.php @@ -1,608 +1,608 @@ inputFile($id, null); if (is_int($file)) { return $this->errorResponse($file); } // Here we're just marking the file as deleted, it will be removed from the // storage later with the fs:expunge command $file->delete(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.file-delete-success'), + 'message' => self::trans('app.file-delete-success'), ]); } /** * Fetch content of a file. * * @param string $id The download (not file) identifier. * * @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse */ public function download($id) { $fileId = Cache::get('download:' . $id); if (!$fileId) { return response('Not found', 404); } $file = Item::find($fileId); if (!$file) { return response('Not found', 404); } return Storage::fileDownload($file); } /** * Fetch the permissions for the specific file. * * @param string $fileId The file identifier. * * @return \Illuminate\Http\JsonResponse */ public function getPermissions($fileId) { // Only the file owner can do that, for now $file = $this->inputFile($fileId, null); if (is_int($file)) { return $this->errorResponse($file); } $result = $file->properties()->where('key', 'like', 'share-%')->get()->map( fn($prop) => self::permissionToClient($prop->key, $prop->value) ); $result = [ 'list' => $result, 'count' => count($result), ]; return response()->json($result); } /** * Add permission for the specific file. * * @param string $fileId The file identifier. * * @return \Illuminate\Http\JsonResponse */ public function createPermission($fileId) { // Only the file owner can do that, for now $file = $this->inputFile($fileId, null); if (is_int($file)) { return $this->errorResponse($file); } // Validate/format input $v = Validator::make(request()->all(), [ 'user' => 'email|required', 'permissions' => 'string|required', ]); $errors = $v->fails() ? $v->errors()->toArray() : []; $acl = self::inputAcl(request()->input('permissions')); if (empty($errors['permissions']) && empty($acl)) { - $errors['permissions'] = \trans('validation.file-perm-invalid'); + $errors['permissions'] = self::trans('validation.file-perm-invalid'); } $user = \strtolower(request()->input('user')); // Check if it already exists if (empty($errors['user'])) { if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$user:%")->exists()) { - $errors['user'] = \trans('validation.file-perm-exists'); + $errors['user'] = self::trans('validation.file-perm-exists'); } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Create the property (with a unique id) while ($shareId = 'share-' . \App\Utils::uuidStr()) { if (!Property::where('key', $shareId)->exists()) { break; } } $file->setProperty($shareId, "$user:$acl"); $result = self::permissionToClient($shareId, "$user:$acl"); return response()->json($result + [ 'status' => 'success', - 'message' => \trans('app.file-permissions-create-success'), + 'message' => self::trans('app.file-permissions-create-success'), ]); } /** * Delete file permission. * * @param string $fileId The file identifier. * @param string $id The file permission identifier. * * @return \Illuminate\Http\JsonResponse */ public function deletePermission($fileId, $id) { // Only the file owner can do that, for now $file = $this->inputFile($fileId, null); if (is_int($file)) { return $this->errorResponse($file); } $property = $file->properties()->where('key', $id)->first(); if (!$property) { return $this->errorResponse(404); } $property->delete(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.file-permissions-delete-success'), + 'message' => self::trans('app.file-permissions-delete-success'), ]); } /** * Update file permission. * * @param \Illuminate\Http\Request $request The API request. * @param string $fileId The file identifier. * @param string $id The file permission identifier. * * @return \Illuminate\Http\JsonResponse */ public function updatePermission(Request $request, $fileId, $id) { // Only the file owner can do that, for now $file = $this->inputFile($fileId, null); if (is_int($file)) { return $this->errorResponse($file); } $property = $file->properties()->where('key', $id)->first(); if (!$property) { return $this->errorResponse(404); } // Validate/format input $v = Validator::make($request->all(), [ 'user' => 'email|required', 'permissions' => 'string|required', ]); $errors = $v->fails() ? $v->errors()->toArray() : []; $acl = self::inputAcl($request->input('permissions')); if (empty($errors['permissions']) && empty($acl)) { - $errors['permissions'] = \trans('validation.file-perm-invalid'); + $errors['permissions'] = self::trans('validation.file-perm-invalid'); } $user = \strtolower($request->input('user')); if (empty($errors['user']) && strpos($property->value, "$user:") !== 0) { if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$user:%")->exists()) { - $errors['user'] = \trans('validation.file-perm-exists'); + $errors['user'] = self::trans('validation.file-perm-exists'); } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $property->value = "$user:$acl"; $property->save(); $result = self::permissionToClient($property->key, $property->value); return response()->json($result + [ 'status' => 'success', - 'message' => \trans('app.file-permissions-update-success'), + 'message' => self::trans('app.file-permissions-update-success'), ]); } /** * Listing of files (and folders). * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 100; $hasMore = false; $user = $this->guard()->user(); $result = $user->fsItems()->select('fs_items.*', 'fs_properties.value as name') ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') ->whereNot('type', '&', Item::TYPE_INCOMPLETE) ->where('key', 'name'); if (strlen($search)) { $result->whereLike('fs_properties.value', $search); } $result = $result->orderBy('name') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($file) { $result = $this->objectToClient($file); $result['name'] = $file->name; // @phpstan-ignore-line return $result; } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Fetch the specific file metadata or content. * * @param string $id The file identifier. * * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\StreamedResponse */ public function show($id) { $file = $this->inputFile($id, self::READ); if (is_int($file)) { return $this->errorResponse($file); } $response = $this->objectToClient($file, true); if (request()->input('downloadUrl')) { // Generate a download URL (that does not require authentication) $downloadId = Utils::uuidStr(); Cache::add('download:' . $downloadId, $file->id, 60); $response['downloadUrl'] = Utils::serviceUrl('api/v4/files/downloads/' . $downloadId); } elseif (request()->input('download')) { // Return the file content return Storage::fileDownload($file); } $response['mtime'] = $file->updated_at->format('Y-m-d H:i'); // TODO: Handle read-write/full access rights $isOwner = $this->guard()->user()->id == $file->user_id; $response['canUpdate'] = $isOwner; $response['canDelete'] = $isOwner; $response['isOwner'] = $isOwner; return response()->json($response); } /** * Create a new file. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // Validate file name input $v = Validator::make($request->all(), ['name' => ['required', new FileName($user)]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $filename = $request->input('name'); $media = $request->input('media'); // FIXME: Normally people just drag and drop/upload files. // The client side will not know whether the file with the same name // already exists or not. So, in such a case should we throw // an error or accept the request as an update? $params = []; if ($media == 'resumable') { $params['uploadId'] = 'resumable'; $params['size'] = $request->input('size'); $params['from'] = $request->input('from') ?: 0; } // TODO: Delete the existing incomplete file with the same name? $file = $user->fsItems()->create(['type' => Item::TYPE_INCOMPLETE | Item::TYPE_FILE]); $file->setProperty('name', $filename); try { $response = Storage::fileInput($request->getContent(true), $params, $file); $response['status'] = 'success'; if (!empty($response['id'])) { $response += $this->objectToClient($file, true); - $response['message'] = \trans('app.file-create-success'); + $response['message'] = self::trans('app.file-create-success'); } } catch (\Exception $e) { \Log::error($e); $file->delete(); return $this->errorResponse(500); } return response()->json($response); } /** * Update a file. * * @param \Illuminate\Http\Request $request The API request. * @param string $id File identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $file = $this->inputFile($id, self::WRITE); if (is_int($file)) { return $this->errorResponse($file); } $media = $request->input('media') ?: 'metadata'; if ($media == 'metadata') { $filename = $request->input('name'); // Validate file name input if ($filename != $file->getProperty('name')) { $v = Validator::make($request->all(), ['name' => [new FileName($file->user)]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $file->setProperty('name', $filename); } // $file->save(); } elseif ($media == 'resumable' || $media == 'content') { $params = []; if ($media == 'resumable') { $params['uploadId'] = 'resumable'; $params['size'] = $request->input('size'); $params['from'] = $request->input('from') ?: 0; } try { $response = Storage::fileInput($request->getContent(true), $params, $file); } catch (\Exception $e) { \Log::error($e); return $this->errorResponse(500); } } else { - $errors = ['media' => \trans('validation.entryinvalid', ['attribute' => 'media'])]; + $errors = ['media' => self::trans('validation.entryinvalid', ['attribute' => 'media'])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $response['status'] = 'success'; if ($media == 'metadata' || !empty($response['id'])) { $response += $this->objectToClient($file, true); - $response['message'] = \trans('app.file-update-success'); + $response['message'] = self::trans('app.file-update-success'); } return response()->json($response); } /** * Upload a file content. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Upload (not file) identifier * * @return \Illuminate\Http\JsonResponse The response */ public function upload(Request $request, $id) { $params = [ 'uploadId' => $id, 'from' => $request->input('from') ?: 0, ]; try { $response = Storage::fileInput($request->getContent(true), $params); $response['status'] = 'success'; if (!empty($response['id'])) { $response += $this->objectToClient(Item::find($response['id']), true); - $response['message'] = \trans('app.file-upload-success'); + $response['message'] = self::trans('app.file-upload-success'); } } catch (\Exception $e) { \Log::error($e); return $this->errorResponse(500); } return response()->json($response); } /** * Convert Permission to an array for the API response. * * @param string $id Permission identifier * @param string $value Permission record * * @return array Permission data */ protected static function permissionToClient(string $id, string $value): array { list($user, $acl) = explode(':', $value); $perms = strpos($acl, self::WRITE) !== false ? 'read-write' : 'read-only'; return [ 'id' => $id, 'user' => $user, 'permissions' => $perms, 'link' => Utils::serviceUrl('file/' . $id), ]; } /** * Convert ACL label into internal permissions spec. * * @param string $acl Access rights label * * @return ?string Permissions ('r' or 'rw') */ protected static function inputAcl($acl): ?string { // The ACL widget supports 'full', 'read-write', 'read-only', if ($acl == 'read-write') { return self::READ . self::WRITE; } if ($acl == 'read-only') { return self::READ; } return null; } /** * Get the input file object, check permissions * * @param string $fileId File or file permission identifier * @param ?string $permission Required access rights * * @return \App\Fs\Item|int File object or error code */ protected function inputFile($fileId, $permission) { $user = $this->guard()->user(); $isShare = str_starts_with($fileId, 'share-'); // Access via file permission identifier if ($isShare) { $property = Property::where('key', $fileId)->first(); if (!$property) { return 404; } list($acl_user, $acl) = explode(':', $property->value); if (!$permission || $acl_user != $user->email || strpos($acl, $permission) === false) { return 403; } $fileId = $property->item_id; } $file = Item::find($fileId); if (!$file || !($file->type & Item::TYPE_FILE) || ($file->type & Item::TYPE_INCOMPLETE)) { return 404; } if (!$isShare && $user->id != $file->user_id) { return 403; } return $file; } /** * Prepare a file object for the UI. * * @param object $object An object * @param bool $full Include all object properties * * @return array Object information */ protected function objectToClient($object, bool $full = false): array { $result = ['id' => $object->id]; if ($full) { $props = array_filter($object->getProperties(['name', 'size', 'mimetype'])); // convert size to int and make sure the property exists $props['size'] = (int) ($props['size'] ?? 0); $result += $props; } return $result; } } diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php index 91b441b0..7c07de1c 100644 --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -1,329 +1,329 @@ true, 'distlist-ldap-ready' => $group->isLdapReady(), ] ); } /** * Create a new group record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $email = $request->input('email'); $members = $request->input('members'); $errors = []; $rules = [ 'name' => 'required|string|max:191', ]; // Validate group address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $errors['email'] = $error; } else { list(, $domainName) = explode('@', $email); $rules['name'] = ['required', 'string', new GroupName($owner, $domainName)]; } // Validate the group name $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = array_merge($errors, $v->errors()->toArray()); } // Validate members' email addresses if (empty($members) || !is_array($members)) { - $errors['members'] = \trans('validation.listmembersrequired'); + $errors['members'] = self::trans('validation.listmembersrequired'); } else { foreach ($members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === \strtolower($email)) { - $errors['members'][$i] = \trans('validation.memberislist'); + $errors['members'][$i] = self::trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create the group $group = new Group(); $group->name = $request->input('name'); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.distlist-create-success'), + 'message' => self::trans('app.distlist-create-success'), ]); } /** * Update a group. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($group)) { return $this->errorResponse(403); } $owner = $group->wallet()->owner; $name = $request->input('name'); $members = $request->input('members'); $errors = []; // Validate the group name if ($name !== null && $name != $group->name) { list(, $domainName) = explode('@', $group->email); $rules = ['name' => ['required', 'string', new GroupName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = array_merge($errors, $v->errors()->toArray()); } else { $group->name = $name; } } // Validate members' email addresses if (empty($members) || !is_array($members)) { - $errors['members'] = \trans('validation.listmembersrequired'); + $errors['members'] = self::trans('validation.listmembersrequired'); } else { foreach ((array) $members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === $group->email) { - $errors['members'][$i] = \trans('validation.memberislist'); + $errors['members'][$i] = self::trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // SkusController::updateEntitlements($group, $request->skus); $group->members = $members; $group->save(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.distlist-update-success'), + 'message' => self::trans('app.distlist-update-success'), ]); } /** * Execute (synchronously) specified step in a group setup process. * * @param \App\Group $group Group object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Group $group, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($group->domain(), $step); } switch ($step) { case 'distlist-ldap-ready': // Group not in LDAP, create it $job = new \App\Jobs\Group\CreateJob($group->id); $job->handle(); $group->refresh(); return $group->isLdapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Validate an email address for use as a group email * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateGroupEmail($email, \App\User $user): ?string { if (empty($email)) { - return \trans('validation.required', ['attribute' => 'email']); + return self::trans('validation.required', ['attribute' => 'email']); } if (strpos($email, '@') === false) { - return \trans('validation.entryinvalid', ['attribute' => 'email']); + return self::trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', \strtolower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { - return \trans('validation.entryinvalid', ['attribute' => 'email']); + return self::trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::where('namespace', $domain)->first(); if (empty($domain)) { - return \trans('validation.domaininvalid'); + return self::trans('validation.domaininvalid'); } $wallet = $domain->wallet(); // The domain must be owned by the user if (!$wallet || !$user->wallets()->find($wallet->id)) { - return \trans('validation.domainnotavailable'); + return self::trans('validation.domainnotavailable'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => [new \App\Rules\UserEmailLocal(true)]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if a user with specified address already exists if (User::emailExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); + return self::trans('validation.entryexists', ['attribute' => 'email']); } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); + return self::trans('validation.entryexists', ['attribute' => 'email']); } if (Group::emailExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); + return self::trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Validate an email address for use as a group member * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateMemberEmail($email, \App\User $user): ?string { $v = Validator::make( ['email' => $email], ['email' => [new \App\Rules\ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // A local domain user must exist if (!User::where('email', \strtolower($email))->first()) { list($login, $domain) = explode('@', \strtolower($email)); $domain = Domain::where('namespace', $domain)->first(); // We return an error only if the domain belongs to the group owner if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) { - return \trans('validation.notalocaluser'); + return self::trans('validation.notalocaluser'); } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/MeetController.php b/src/app/Http/Controllers/API/V4/MeetController.php index 51da0f96..e4d03df8 100644 --- a/src/app/Http/Controllers/API/V4/MeetController.php +++ b/src/app/Http/Controllers/API/V4/MeetController.php @@ -1,190 +1,190 @@ first(); // Room does not exist, or the owner is deleted if (!$room || !($wallet = $room->wallet()) || !$wallet->owner || $wallet->owner->isDegraded(true)) { - return $this->errorResponse(404, \trans('meet.room-not-found')); + return $this->errorResponse(404, self::trans('meet.room-not-found')); } $user = Auth::guard()->user(); $isOwner = $user && ( $user->id == $wallet->owner->id || $room->permissions()->where('user', $user->email)->exists() ); $init = !empty(request()->input('init')); // There's no existing session if (!$room->hasSession()) { // Participants can't join the room until the session is created by the owner if (!$isOwner) { - return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]); + return $this->errorResponse(422, self::trans('meet.session-not-found'), ['code' => 323]); } // The room owner can create the session on request if (!$init) { - return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]); + return $this->errorResponse(422, self::trans('meet.session-not-found'), ['code' => 324]); } $session = $room->createSession(); if (empty($session)) { - return $this->errorResponse(500, \trans('meet.session-create-error')); + return $this->errorResponse(500, self::trans('meet.session-create-error')); } } $settings = $room->getSettings(['locked', 'nomedia', 'password']); $password = (string) $settings['password']; $config = [ 'locked' => $settings['locked'] === 'true', 'nomedia' => $settings['nomedia'] === 'true', 'password' => $isOwner ? $password : '', 'requires_password' => !$isOwner && strlen($password), ]; $response = ['config' => $config]; // Validate room password if (!$isOwner && strlen($password)) { $request_password = request()->input('password'); if ($request_password !== $password) { - return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]); + return $this->errorResponse(422, self::trans('meet.session-password-error'), $response + ['code' => 325]); } } // Handle locked room if (!$isOwner && $config['locked']) { $nickname = request()->input('nickname'); $picture = request()->input('picture'); $requestId = request()->input('requestId'); $request = $requestId ? $room->requestGet($requestId) : null; - $error = \trans('meet.session-room-locked-error'); + $error = self::trans('meet.session-room-locked-error'); // Request already has been processed (not accepted yet, but it could be denied) if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { if (!$request) { if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { return $this->errorResponse(422, $error, $response + ['code' => 326]); } if (empty($picture)) { $svg = file_get_contents(resource_path('images/user.svg')); $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { return $this->errorResponse(422, $error, $response + ['code' => 326]); } // TODO: Resize when big/make safe the user picture? $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; if (!$room->requestSave($requestId, $request)) { // FIXME: should we use error code 500? return $this->errorResponse(422, $error, $response + ['code' => 326]); } // Send the request (signal) to all moderators $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); } return $this->errorResponse(422, $error, $response + ['code' => 327]); } } // Initialize connection tokens if ($init) { // Choose the connection role $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner); $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER; if ($isOwner) { $role |= Room::ROLE_MODERATOR; $role |= Room::ROLE_OWNER; } // Create session token for the current user/connection $response = $room->getSessionToken($role); if (empty($response)) { - return $this->errorResponse(500, \trans('meet.session-join-error')); + return $this->errorResponse(500, self::trans('meet.session-join-error')); } $response_code = 200; $response['role'] = $role; $response['config'] = $config; } else { $response_code = 422; $response['code'] = 322; } return response()->json($response, $response_code); } /** * Webhook as triggered from the Meet server * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function webhook(Request $request) { \Log::debug($request->getContent()); // Authenticate the request if ($request->headers->get('X-Auth-Token') != \config('meet.webhook_token')) { return response('Unauthorized', 403); } $sessionId = (string) $request->input('roomId'); $event = (string) $request->input('event'); switch ($event) { case 'roomClosed': // When all participants left the room the server will dispatch roomClosed // event. We'll remove the session reference from the database. $room = Room::where('session_id', $sessionId)->first(); if ($room) { $room->session_id = null; $room->save(); } break; case 'joinRequestAccepted': case 'joinRequestDenied': $room = Room::where('session_id', $sessionId)->first(); if ($room) { $method = $event == 'joinRequestAccepted' ? 'requestAccept' : 'requestDeny'; $room->{$method}($request->input('requestId')); } break; } return response('Success', 200); } } diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index d47b216e..d0a79130 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,591 +1,591 @@ 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') . ' Auto-Payment Setup', '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 < intval($request->balance * 100)) { $mandate['amount'] = intval($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' => \trans('app.mandate-delete-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 < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; - $result['message'] = \trans('app.mandate-update-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') . ' Auto-Payment Setup', '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) ($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' => \trans("validation.{$label}", ['amount' => $wallet->money($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' => \trans($label), + '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) ($request->amount * 100); // Validate the minimum value if ($amount < Payment::MIN_AMOUNT) { $min = $wallet->money(Payment::MIN_AMOUNT); - $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + $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) (floatval($settings['mandate_balance']) * 100); $amount = (int) (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); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $request = [ 'type' => Payment::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; 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/Reseller/DomainsController.php b/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php index 2c2e8332..9bebe31a 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php @@ -1,55 +1,55 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::withSubjectTenantContext()->find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::withSubjectTenantContext()->where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map( function ($domain) { return $this->objectToClient($domain); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php index 5f51beeb..c258e8d0 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php @@ -1,46 +1,46 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::withSubjectTenantContext()->find($owner)) { $result = $owner->groups(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($group = Group::withSubjectTenantContext()->where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map( function ($group) { return $this->objectToClient($group); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php b/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php index dea45808..7db10ccc 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/InvitationsController.php @@ -1,261 +1,261 @@ errorResponse(404); } /** * Remove the specified invitation. * * @param int $id Invitation identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { $invitation = SignupInvitation::withSubjectTenantContext()->find($id); if (empty($invitation)) { return $this->errorResponse(404); } $invitation->delete(); return response()->json([ 'status' => 'success', 'message' => trans('app.signup-invitation-delete-success'), ]); } /** * Show the form for editing the specified resource. * * @param int $id Invitation identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Display a listing of the resource. * * @return \Illuminate\Http\JsonResponse */ public function index() { $pageSize = 10; $search = request()->input('search'); $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = SignupInvitation::withSubjectTenantContext() ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)); if ($search) { if (strpos($search, '@')) { $result->where('email', $search); } else { $result->whereLike('email', $search); } } $result = $result->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($invitation) { return $this->invitationToArray($invitation); }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Resend the specified invitation. * * @param int $id Invitation identifier * * @return \Illuminate\Http\JsonResponse */ public function resend($id) { $invitation = SignupInvitation::withSubjectTenantContext()->find($id); if (empty($invitation)) { return $this->errorResponse(404); } if ($invitation->isFailed() || $invitation->isSent()) { // Note: The email sending job will be dispatched by the observer $invitation->status = SignupInvitation::STATUS_NEW; $invitation->save(); } return response()->json([ 'status' => 'success', 'message' => trans('app.signup-invitation-resend-success'), 'invitation' => $this->invitationToArray($invitation), ]); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { $errors = []; $invitations = []; $envTenantId = \config('app.tenant_id'); $subjectTenantId = auth()->user()->tenant_id; if (!empty($request->file) && is_object($request->file)) { // Expected a text/csv file with multiple email addresses if (!$request->file->isValid()) { $errors = ['file' => [$request->file->getErrorMessage()]]; } else { $fh = fopen($request->file->getPathname(), 'r'); $line_number = 0; $error = null; while ($line = fgetcsv($fh)) { $line_number++; // @phpstan-ignore-next-line if (count($line) >= 1 && $line[0]) { $email = trim($line[0]); if (strpos($email, '@')) { $v = Validator::make(['email' => $email], ['email' => 'email:filter|required']); if ($v->fails()) { $args = ['email' => $email, 'line' => $line_number]; $error = trans('app.signup-invitations-csv-invalid-email', $args); break; } $invitations[] = ['email' => $email]; } } } fclose($fh); if ($error) { $errors = ['file' => $error]; } elseif (empty($invitations)) { $errors = ['file' => trans('app.signup-invitations-csv-empty')]; } } } else { // Expected 'email' field with an email address $v = Validator::make($request->all(), ['email' => 'email|required']); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $invitations[] = ['email' => $request->email]; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $count = 0; foreach ($invitations as $idx => $invitation) { $inv = SignupInvitation::create($invitation); $count++; // Set the invitation tenant to the reseller tenant if ($envTenantId != $subjectTenantId) { $inv->tenant_id = $subjectTenantId; $inv->save(); } } return response()->json([ 'status' => 'success', - 'message' => \trans_choice('app.signup-invitations-created', $count, ['count' => $count]), + 'message' => self::trans_choice('app.signup-invitations-created', $count, ['count' => $count]), 'count' => $count, ]); } /** * Display the specified resource. * * @param int $id Invitation identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { return $this->errorResponse(404); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Convert an invitation object to an array for output * * @param \App\SignupInvitation $invitation The signup invitation object * * @return array */ protected static function invitationToArray(SignupInvitation $invitation): array { return [ 'id' => $invitation->id, 'email' => $invitation->email, 'isNew' => $invitation->isNew(), 'isSent' => $invitation->isSent(), 'isFailed' => $invitation->isFailed(), 'isCompleted' => $invitation->isCompleted(), 'created' => $invitation->created_at->toDateTimeString(), ]; } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php index 7e860cf4..66a62abc 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php @@ -1,46 +1,46 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::withSubjectTenantContext()->find($owner)) { $result = $owner->resources(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($resource = Resource::withSubjectTenantContext()->where('email', $search)->first()) { $result->push($resource); } } // Process the result $result = $result->map( function ($resource) { return $this->objectToClient($resource); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxresources', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxresources', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php index a2ba6abe..143abdbe 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php @@ -1,46 +1,46 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::withSubjectTenantContext()->find($owner)) { $result = $owner->sharedFolders(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) { $result->push($folder); } } // Process the result $result = $result->map( function ($folder) { return $this->objectToClient($folder); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxsharedfolders', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php index 1fd8dcaa..5cffc6e3 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php @@ -1,114 +1,114 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { $owner = User::where('id', $owner) ->withSubjectTenantContext() ->whereNull('role') ->first(); if ($owner) { $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) ->withSubjectTenantContext() ->whereNull('role') ->orderBy('email') ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = UserSetting::where('key', 'external_email') ->where('value', $search) ->get() ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by an email of a group, resource, shared folder, etc. if ($group = Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } elseif ($alias = \App\SharedFolderAlias::where('alias', $search)->first()) { $user_ids = $user_ids->merge([$alias->sharedFolder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) ->withSubjectTenantContext() ->whereNull('role') ->orderBy('email') ->get(); } } } elseif (is_numeric($search)) { // Search by user ID $user = User::withTrashed()->where('id', $search) ->withSubjectTenantContext() ->whereNull('role') ->first(); if ($user) { $result->push($user); } } elseif (!empty($search)) { // Search by domain $domain = Domain::withTrashed()->where('namespace', $search) ->withSubjectTenantContext() ->first(); if ($domain) { if ( ($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->withSubjectTenantContext()->first()) && empty($owner->role) ) { $result->push($owner); } } } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user, true); } ); $result = [ 'list' => $result, 'count' => count($result), - 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), + 'message' => self::trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php index 9eaf3f36..2ada4085 100644 --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -1,174 +1,174 @@ true, 'resource-ldap-ready' => $resource->isLdapReady(), 'resource-imap-ready' => $resource->isImapReady(), ] ); } /** * Create a new resource record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $domain = request()->input('domain'); $rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } DB::beginTransaction(); // Create the resource $resource = new Resource(); $resource->name = request()->input('name'); $resource->domainName = $domain; $resource->save(); $resource->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.resource-create-success'), + 'message' => self::trans('app.resource-create-success'), ]); } /** * Update a resource. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Resource identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $resource = Resource::find($id); if (!$this->checkTenant($resource)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($resource)) { return $this->errorResponse(403); } $owner = $resource->wallet()->owner; $name = $request->input('name'); $errors = []; // Validate the resource name if ($name !== null && $name != $resource->name) { $domainName = explode('@', $resource->email, 2)[1]; $rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]]; $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } else { $resource->name = $name; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // SkusController::updateEntitlements($resource, $request->skus); $resource->save(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.resource-update-success'), + 'message' => self::trans('app.resource-update-success'), ]); } /** * Execute (synchronously) specified step in a resource setup process. * * @param \App\Resource $resource Resource object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Resource $resource, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($resource->domain(), $step); } switch ($step) { case 'resource-ldap-ready': case 'resource-imap-ready': // Use worker to do the job, frontend might not have the IMAP admin credentials \App\Jobs\Resource\CreateJob::dispatch($resource->id); return null; } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/RoomsController.php b/src/app/Http/Controllers/API/V4/RoomsController.php index 3b4d6ab3..f428f926 100644 --- a/src/app/Http/Controllers/API/V4/RoomsController.php +++ b/src/app/Http/Controllers/API/V4/RoomsController.php @@ -1,314 +1,314 @@ inputRoom($id); if (is_int($room)) { return $this->errorResponse($room); } $room->delete(); return response()->json([ 'status' => 'success', - 'message' => \trans("app.room-delete-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() ->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' => \trans("app.room-setconfig-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' => \trans("app.room-create-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(); if (!empty($request->skus)) { SkusController::updateEntitlements($room, $request->skus); } if (!$room->hasSKU('group-room')) { $room->setSetting('acl', null); } DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans("app.room-update-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/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php index 43926d19..c426ae1c 100644 --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -1,267 +1,267 @@ true, 'shared-folder-ldap-ready' => $folder->isLdapReady(), 'shared-folder-imap-ready' => $folder->isImapReady(), ] ); } /** * Create a new shared folder record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->walletOwner(); if (empty($owner) || $owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateFolderRequest($request, null, $owner)) { return $error_response; } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); $folder->name = $request->input('name'); $folder->type = $request->input('type'); $folder->domainName = $request->input('domain'); $folder->save(); if (!empty($request->aliases) && $folder->type === 'mail') { $folder->setAliases($request->aliases); } $folder->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.shared-folder-create-success'), + 'message' => self::trans('app.shared-folder-create-success'), ]); } /** * Update a shared folder. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Shared folder identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $folder = SharedFolder::find($id); if (!$this->checkTenant($folder)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($folder)) { return $this->errorResponse(403); } if ($error_response = $this->validateFolderRequest($request, $folder, $folder->walletOwner())) { return $error_response; } $name = $request->input('name'); DB::beginTransaction(); // SkusController::updateEntitlements($folder, $request->skus); if ($name && $name != $folder->name) { $folder->name = $name; } $folder->save(); if (isset($request->aliases) && $folder->type === 'mail') { $folder->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.shared-folder-update-success'), + 'message' => self::trans('app.shared-folder-update-success'), ]); } /** * Execute (synchronously) specified step in a shared folder setup process. * * @param \App\SharedFolder $folder Shared folder object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(SharedFolder $folder, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($folder->domain(), $step); } switch ($step) { case 'shared-folder-ldap-ready': case 'shared-folder-imap-ready': // Use worker to do the job, frontend might not have the IMAP admin credentials \App\Jobs\SharedFolder\CreateJob::dispatch($folder->id); return null; } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Validate shared folder input * * @param \Illuminate\Http\Request $request The API request. * @param \App\SharedFolder|null $folder Shared folder * @param \App\User|null $owner Account owner * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateFolderRequest(Request $request, $folder, $owner) { $errors = []; if (empty($folder)) { $name = $request->input('name'); $domain = $request->input('domain'); $rules = [ 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], 'type' => ['required', 'string', new SharedFolderType()], ]; } else { // On update validate the folder name (if changed) $name = $request->input('name'); $domain = explode('@', $folder->email, 2)[1]; if ($name !== null && $name != $folder->name) { $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domain)]]; } } if (!empty($rules)) { $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $owner->aliases()->get()->pluck('alias')->toArray(); foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address if (!empty($folder) && Str::lower($alias) == Str::lower($folder->email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $owner, $name, $domain)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return null; } /** * Email address validation for use as a shared folder alias. * * @param string $alias Email address * @param \App\User $owner The account owner * @param string $folderName Folder name * @param string $domain Folder domain * * @return ?string Error message on validation error */ public static function validateAlias(string $alias, \App\User $owner, string $folderName, string $domain): ?string { $lmtp_alias = "shared+shared/{$folderName}@{$domain}"; if ($alias === $lmtp_alias) { return null; } return UsersController::validateAlias($alias, $owner); } } diff --git a/src/app/Http/Controllers/API/V4/SupportController.php b/src/app/Http/Controllers/API/V4/SupportController.php index 84e7ae9c..041b34a6 100644 --- a/src/app/Http/Controllers/API/V4/SupportController.php +++ b/src/app/Http/Controllers/API/V4/SupportController.php @@ -1,69 +1,69 @@ 'string|nullable|max:256', 'name' => 'string|nullable|max:256', 'email' => 'required|email', 'summary' => 'required|string|max:512', 'body' => 'required|string', ]; $params = $request->only(array_keys($rules)); // Check required fields $v = Validator::make($params, $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $to = \config('app.support_email'); if (empty($to)) { \Log::error("Failed to send a support request. SUPPORT_EMAIL not set"); - return $this->errorResponse(500, \trans('app.support-request-error')); + return $this->errorResponse(500, self::trans('app.support-request-error')); } $content = sprintf( "ID: %s\nName: %s\nWorking email address: %s\nSubject: %s\n\n%s\n", $params['user'] ?? '', $params['name'] ?? '', $params['email'], $params['summary'], $params['body'], ); Mail::raw($content, function ($message) use ($params, $to) { // Remove the global reply-to addressee $message->getHeaders()->remove('Reply-To'); $message->to($to) ->from($params['email'], $params['name'] ?? null) ->replyTo($params['email'], $params['name'] ?? null) ->subject($params['summary']); }); return response()->json([ 'status' => 'success', - 'message' => \trans('app.support-request-success'), + 'message' => self::trans('app.support-request-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 0a2cedfc..b38ec49e 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,711 +1,711 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); $response['aliases'] = $user->aliases()->pluck('alias')->all(); $code = $user->verificationcodes()->where('active', true) ->where('expires_at', '>', \Carbon\Carbon::now()) ->first(); if ($code) { $response['passwordLinkCode'] = $code->short_code . '-' . $code->code; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $isDegraded = $user->isDegraded(); $hasMeet = !$isDegraded && Sku::withObjectTenantContext($user)->where('title', 'room')->exists(); // Enable all features if there are no skus for domain-hosting $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0 || !Sku::withObjectTenantContext($user)->where('title', 'domain-hosting')->exists(); // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $hasBeta = in_array('beta', $skus) || !Sku::withObjectTenantContext($user)->where('title', 'beta')->exists(); $plan = $isController ? $user->wallet()->plan() : null; $result = [ 'skus' => $skus, 'enableBeta' => $hasBeta, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && $hasBeta, 'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && $hasBeta, // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && $hasBeta, 'enableRooms' => $hasMeet, 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController && \config('app.with_wallet'), 'enableWalletMandates' => $isController, 'enableWalletPayments' => $isController && (!$plan || $plan->mode != Plan::MODE_MANDATE), 'enableCompanionapps' => $hasBeta, ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { - $errors = ['package' => \trans('validation.packagerequired')]; + $errors = ['package' => self::trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { - $errors = ['package' => \trans('validation.packageinvalid')]; + $errors = ['package' => self::trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, 'status' => $owner->isRestricted() ? User::STATUS_RESTRICTED : 0, ]); $this->activatePassCode($user); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', - 'message' => \trans('app.user-create-success'), + 'message' => self::trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); SkusController::updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } $this->activatePassCode($user); if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); $response = [ 'status' => 'success', - 'message' => \trans('app.user-update-success'), + 'message' => self::trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); $wallet = $user->wallet(); // IsLocked flag to lock the user to the Wallet page only $response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($wallet); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { $state = parent::objectState($user); $state['isAccountDegraded'] = $user->isDegraded(true); return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; $controller = ($user ?: $this->guard()->user())->walletOwner(); // Handle generated password reset code if ($code = $request->input('passwordLinkCode')) { // Accept - input if (strpos($code, '-')) { $code = explode('-', $code)[1]; } $this->passCode = $this->guard()->user()->verificationcodes() ->where('code', $code)->where('active', false)->first(); // Generate a password for a new user with password reset link // FIXME: Should/can we have a user with no password set? if ($this->passCode && empty($user)) { $request->password = $request->password_confirmation = Str::random(16); $ignorePassword = true; } } if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { if (empty($ignorePassword)) { $rules['password'] = ['required', 'confirmed', new Password($controller)]; } } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { - $errors['email'] = \trans('validation.required', ['attribute' => 'email']); + $errors['email'] = self::trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($user->domain(), $step); } switch ($step) { case 'user-ldap-ready': case 'user-imap-ready': // Use worker to do the job, frontend might not have the IMAP admin credentials \App\Jobs\User\CreateJob::dispatch($user->id); return null; } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { - return \trans('validation.entryinvalid', ['attribute' => 'email']); + return self::trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { - return \trans('validation.entryinvalid', ['attribute' => 'email']); + return self::trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { - return \trans('validation.domaininvalid'); + return self::trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { - return \trans('validation.entryexists', ['attribute' => 'domain']); + return self::trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user/group/resource/shared folder with specified address already exists if ( ($existing = User::emailExists($email, true)) || ($existing = \App\Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) || ($existing = \App\SharedFolder::emailExists($email, true)) ) { // If this is a deleted user/group/resource/folder in the same custom domain // we'll force delete it before creating the target user if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { - return \trans('validation.entryexists', ['attribute' => 'email']); + return self::trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); + return self::trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { - return \trans('validation.entryinvalid', ['attribute' => 'alias']); + return self::trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { - return \trans('validation.entryinvalid', ['attribute' => 'alias']); + return self::trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { - return \trans('validation.domaininvalid'); + return self::trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { - return \trans('validation.entryexists', ['attribute' => 'domain']); + return self::trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { - return \trans('validation.entryexists', ['attribute' => 'alias']); + return self::trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group/resource/shared folder with specified address already exists if ( \App\Group::emailExists($email) || \App\Resource::emailExists($email) || \App\SharedFolder::emailExists($email) ) { - return \trans('validation.entryexists', ['attribute' => 'alias']); + return self::trans('validation.entryexists', ['attribute' => 'alias']); } // Check if an alias with specified address already exists if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { - return \trans('validation.entryexists', ['attribute' => 'alias']); + return self::trans('validation.entryexists', ['attribute' => 'alias']); } } return null; } /** * Activate password reset code (if set), and assign it to a user. * * @param \App\User $user The user */ protected function activatePassCode(User $user): void { // Activate the password reset code if ($this->passCode) { $this->passCode->user_id = $user->id; $this->passCode->active = true; $this->passCode->save(); } } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index 719f7479..82f446a0 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,279 +1,279 @@ checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->toArray(); $provider = PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['notice'] = $this->getWalletNotice($wallet); return response()->json($result); } /** * Download a receipt in pdf format. * * @param string $id Wallet identifier * @param string $receipt Receipt identifier (YYYY-MM) * * @return \Illuminate\Http\Response */ public function receiptDownload($id, $receipt) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { abort(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { abort(403); } list ($year, $month) = explode('-', $receipt); if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { abort(404); } if ($receipt >= date('Y-m')) { abort(404); } $params = [ 'id' => sprintf('%04d-%02d', $year, $month), 'site' => \config('app.name') ]; - $filename = \trans('documents.receipt-filename', $params); + $filename = self::trans('documents.receipt-filename', $params); $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); $content = $receipt->pdfOutput(); return response($content) ->withHeaders([ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => strlen($content), ]); } /** * Fetch wallet receipts list. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function receipts($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->payments() ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') ->where('status', Payment::STATUS_PAID) ->where('amount', '<>', 0) ->orderBy('ident', 'desc') ->get() ->whereNotIn('ident', [date('Y-m')]) // exclude current month ->pluck('ident'); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => false, 'page' => 1, ]); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet /** @var ?\App\Transaction $transaction */ $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin, $wallet) { $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $item->amount, 'currency' => $wallet->currency, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Returns human readable notice about the wallet state. * * @param \App\Wallet $wallet The wallet */ protected function getWalletNotice(Wallet $wallet): ?string { // there is no credit if ($wallet->balance < 0) { - return \trans('app.wallet-notice-nocredit'); + return self::trans('app.wallet-notice-nocredit'); } // the discount is 100%, no credit is needed if ($wallet->discount && $wallet->discount->discount == 100) { return null; } $plan = $wallet->plan(); $freeMonths = $plan ? $plan->free_months : 0; $trialEnd = $freeMonths ? $wallet->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; // the owner is still in the trial period if ($trialEnd && $trialEnd > Carbon::now()) { // notice of trial ending if less than 2 weeks left if ($trialEnd < Carbon::now()->addWeeks(2)) { - return \trans('app.wallet-notice-trial-end'); + return self::trans('app.wallet-notice-trial-end'); } - return \trans('app.wallet-notice-trial'); + return self::trans('app.wallet-notice-trial'); } if ($until = $wallet->balanceLastsUntil()) { if ($until->isToday()) { - return \trans('app.wallet-notice-today'); + return self::trans('app.wallet-notice-today'); } // Once in a while we got e.g. "3 weeks" instead of expected "4 weeks". // It's because $until uses full seconds, but $now is more precise. // We make sure both have the same time set. $now = Carbon::now()->setTimeFrom($until); $diffOptions = [ 'syntax' => Carbon::DIFF_ABSOLUTE, 'parts' => 1, ]; if ($now->diff($until)->days > 31) { $diffOptions['parts'] = 2; } $params = [ 'date' => $until->toDateString(), 'days' => $now->diffForHumans($until, $diffOptions), ]; - return \trans('app.wallet-notice-date', $params); + return self::trans('app.wallet-notice-date', $params); } return null; } }