diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1031,6 +1031,7 @@ $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; + $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** @@ -1073,7 +1074,7 @@ $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; - $entry['alias'] = $user->aliases->pluck('alias')->toArray(); + $entry['alias'] = $user->aliases()->pluck('alias')->all(); $roles = []; @@ -1193,7 +1194,7 @@ $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); - $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl']; + $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have diff --git a/src/app/Console/Commands/Data/Import/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php --- a/src/app/Console/Commands/Data/Import/LdifCommand.php +++ b/src/app/Console/Commands/Data/Import/LdifCommand.php @@ -342,7 +342,7 @@ $resource = new \App\Resource(); $resource->name = $data->name; - $resource->domain = $data->domain; + $resource->domainName = $data->domain; $resource->save(); $resource->assignToWallet($this->wallet); @@ -396,7 +396,7 @@ $folder = new \App\SharedFolder(); $folder->name = $data->name; $folder->type = $data->type ?? 'mail'; - $folder->domain = $data->domain; + $folder->domainName = $data->domain; $folder->save(); $folder->assignToWallet($this->wallet); @@ -410,6 +410,11 @@ if (!empty($data->folder)) { $folder->setSetting('folder', $data->folder); } + + // Import aliases + if (!empty($data->aliases)) { + $this->setObjectAliases($folder, $data->aliases); + } } $bar->finish(); @@ -431,7 +436,7 @@ // Import aliases of the owner, we got from importOwner() call if (!empty($this->aliases) && $this->wallet) { - $this->setUserAliases($this->wallet->owner, $this->aliases); + $this->setObjectAliases($this->wallet->owner, $this->aliases); } $bar = $this->createProgressBar($users->count(), "Importing users"); @@ -537,7 +542,7 @@ // domain records yet, save the aliases to be inserted later (in importUsers()) $this->aliases = $data->aliases; } else { - $this->setUserAliases($user, $data->aliases); + $this->setObjectAliases($user, $data->aliases); } } @@ -706,6 +711,10 @@ if (!empty($entry['acl'])) { $result['acl'] = $this->parseACL($this->attrArrayValue($entry, 'acl')); } + + if (!empty($entry['alias'])) { + $result['aliases'] = $this->attrArrayValue($entry, 'alias'); + } } return [$result, $error]; @@ -957,14 +966,14 @@ } /** - * Set aliases for the user + * Set aliases for for an object */ - protected function setUserAliases(\App\User $user, array $aliases = []) + protected function setObjectAliases($object, array $aliases = []) { if (!empty($aliases)) { // Some users might have alias entry with their main address, remove it $aliases = array_map('strtolower', $aliases); - $aliases = array_diff(array_unique($aliases), [$user->email]); + $aliases = array_diff(array_unique($aliases), [$object->email]); // Remove aliases for domains that do not exist if (!empty($aliases)) { @@ -977,7 +986,7 @@ } if (!empty($aliases)) { - $user->setAliases($aliases); + $object->setAliases($aliases); } } } diff --git a/src/app/Console/Commands/SharedFolder/AddAliasCommand.php b/src/app/Console/Commands/SharedFolder/AddAliasCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/SharedFolder/AddAliasCommand.php @@ -0,0 +1,58 @@ +getSharedFolder($this->argument('folder')); + + if (!$folder) { + $this->error("Folder not found."); + return 1; + } + + $alias = \strtolower($this->argument('alias')); + + // Check if the alias already exists + if ($folder->aliases()->where('alias', $alias)->first()) { + $this->error("Address is already assigned to the folder."); + return 1; + } + + // Validate the alias + $error = UsersController::validateAlias($alias, $folder->walletOwner()); + + if ($error) { + if (!$this->option('force')) { + $this->error($error); + return 1; + } + } + + $folder->aliases()->create(['alias' => $alias]); + } +} diff --git a/src/app/Console/Commands/User/AliasesCommand.php b/src/app/Console/Commands/SharedFolder/AliasesCommand.php copy from src/app/Console/Commands/User/AliasesCommand.php copy to src/app/Console/Commands/SharedFolder/AliasesCommand.php --- a/src/app/Console/Commands/User/AliasesCommand.php +++ b/src/app/Console/Commands/SharedFolder/AliasesCommand.php @@ -1,6 +1,6 @@ getUser($this->argument('user')); + $folder = $this->getSharedFolder($this->argument('folder')); - if (!$user) { - $this->error("User not found."); + if (!$folder) { + $this->error("Folder not found."); return 1; } - foreach ($user->aliases as $alias) { - $this->info("{$alias->alias}"); + foreach ($folder->aliases()->pluck('alias')->all() as $alias) { + $this->info($alias); } } } diff --git a/src/app/Console/Commands/SharedFolder/CreateCommand.php b/src/app/Console/Commands/SharedFolder/CreateCommand.php --- a/src/app/Console/Commands/SharedFolder/CreateCommand.php +++ b/src/app/Console/Commands/SharedFolder/CreateCommand.php @@ -79,7 +79,7 @@ $folder = new SharedFolder(); $folder->name = $name; $folder->type = $type; - $folder->domain = $domainName; + $folder->domainName = $domainName; $folder->save(); $folder->assignToWallet($owner->wallets->first()); diff --git a/src/app/Console/Commands/SharedFolderAliasesCommand.php b/src/app/Console/Commands/SharedFolderAliasesCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/SharedFolderAliasesCommand.php @@ -0,0 +1,13 @@ +aliases as $alias) { - $this->info("{$alias->alias}"); + foreach ($user->aliases()->pluck('alias')->all() as $alias) { + $this->info($alias); } } } diff --git a/src/app/Group.php b/src/app/Group.php --- a/src/app/Group.php +++ b/src/app/Group.php @@ -4,6 +4,7 @@ use App\Traits\BelongsToTenantTrait; use App\Traits\EntitleableTrait; +use App\Traits\EmailPropertyTrait; use App\Traits\GroupConfigTrait; use App\Traits\SettingsTrait; use App\Traits\StatusPropertyTrait; @@ -26,6 +27,7 @@ { use BelongsToTenantTrait; use EntitleableTrait; + use EmailPropertyTrait; use GroupConfigTrait; use SettingsTrait; use SoftDeletes; @@ -52,43 +54,6 @@ /** - * Returns group domain. - * - * @return ?\App\Domain The domain group belongs to, NULL if it does not exist - */ - public function domain(): ?Domain - { - list($local, $domainName) = explode('@', $this->email); - - return Domain::where('namespace', $domainName)->first(); - } - - /** - * Find whether an email address exists as a group (including deleted groups). - * - * @param string $email Email address - * @param bool $return_group Return Group instance instead of boolean - * - * @return \App\Group|bool True or Group model object if found, False otherwise - */ - public static function emailExists(string $email, bool $return_group = false) - { - if (strpos($email, '@') === false) { - return false; - } - - $email = \strtolower($email); - - $group = self::withTrashed()->where('email', $email)->first(); - - if ($group) { - return $return_group ? $group : true; - } - - return false; - } - - /** * Group members propert accessor. Converts internal comma-separated list into an array * * @param string $members Comma-separated list of email addresses @@ -101,16 +66,6 @@ } /** - * Ensure the email is appropriately cased. - * - * @param string $email Group email address - */ - public function setEmailAttribute(string $email) - { - $this->attributes['email'] = strtolower($email); - } - - /** * Ensure the members are appropriately formatted. * * @param array $members Email addresses of the group members diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -58,13 +58,15 @@ $user_ids = $user_ids->merge($ext_user_ids)->unique(); - // Search by a distribution list or resource email + // 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()) { diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php --- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php @@ -50,13 +50,15 @@ $user_ids = $user_ids->merge($ext_user_ids)->unique(); - // Search by a distribution list email + // 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()) { diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php --- a/src/app/Http/Controllers/API/V4/ResourcesController.php +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -74,7 +74,7 @@ // Create the resource $resource = new Resource(); $resource->name = request()->input('name'); - $resource->domain = $domain; + $resource->domainName = $domain; $resource->save(); $resource->assignToWallet($owner->wallets->first()); diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -9,6 +9,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; class SharedFoldersController extends RelationController { @@ -54,34 +55,29 @@ public function store(Request $request) { $current_user = $this->guard()->user(); - $owner = $current_user->wallet()->owner; + $owner = $current_user->walletOwner(); - if ($owner->id != $current_user->id) { + if (empty($owner) || $owner->id != $current_user->id) { return $this->errorResponse(403); } - $domain = request()->input('domain'); - - $rules = [ - 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], - 'type' => ['required', 'string', new SharedFolderType()] - ]; - - $v = Validator::make($request->all(), $rules); - - if ($v->fails()) { - return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + 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->domain = $domain; + $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(); @@ -114,30 +110,25 @@ return $this->errorResponse(403); } - $owner = $folder->wallet()->owner; + if ($error_response = $this->validateFolderRequest($request, $folder, $folder->walletOwner())) { + return $error_response; + } $name = $request->input('name'); - $errors = []; - // Validate the folder name - if ($name !== null && $name != $folder->name) { - $domainName = explode('@', $folder->email, 2)[1]; - $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]]; - - $v = Validator::make($request->all(), $rules); + DB::beginTransaction(); - if ($v->fails()) { - $errors = $v->errors()->toArray(); - } else { - $folder->name = $name; - } + if ($name && $name != $folder->name) { + $folder->name = $name; } - if (!empty($errors)) { - return response()->json(['status' => 'error', 'errors' => $errors], 422); + $folder->save(); + + if (isset($request->aliases) && $folder->type === 'mail') { + $folder->setAliases($request->aliases); } - $folder->save(); + DB::commit(); return response()->json([ 'status' => 'success', @@ -194,4 +185,78 @@ 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)) { + $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'); + if ($name !== null && $name != $folder->name) { + $domain = explode('@', $folder->email, 2)[1]; + $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 = UsersController::validateAlias($alias, $owner)) + ) { + 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; + } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -4,7 +4,6 @@ use App\Http\Controllers\RelationController; use App\Domain; -use App\Group; use App\Rules\Password; use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; @@ -133,6 +132,7 @@ $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()) @@ -207,7 +207,7 @@ public function store(Request $request) { $current_user = $this->guard()->user(); - $owner = $current_user->wallet()->owner; + $owner = $current_user->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); @@ -394,12 +394,6 @@ $response['settings'][$item->key] = $item->value; } - // Aliases - $response['aliases'] = []; - foreach ($user->aliases as $item) { - $response['aliases'][] = $item->alias; - } - // Status info $response['statusInfo'] = self::statusInfo($user); @@ -652,33 +646,19 @@ } // Check if it is one of domains available to the user - if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { + if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } - // Check if a user with specified address already exists - if ($existing_user = User::emailExists($email, true)) { - // If this is a deleted user in the same custom domain - // we'll force delete him before - if (!$domain->isPublic() && $existing_user->trashed()) { - $deleted = $existing_user; - } else { - return \trans('validation.entryexists', ['attribute' => 'email']); - } - } - - // Check if an alias with specified address already exists. - if (User::aliasExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); - } - - // Check if a group or resource with specified address already exists + // Check if a user/group/resource/shared folder with specified address already exists if ( - ($existing = Group::emailExists($email, true)) + ($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 group/resource in the same custom domain - // we'll force delete it before + // 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 { @@ -686,6 +666,11 @@ } } + // Check if an alias with specified address already exists. + if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { + return \trans('validation.entryexists', ['attribute' => 'email']); + } + return null; } @@ -727,7 +712,7 @@ } // Check if it is one of domains available to the user - if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { + if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } @@ -739,8 +724,17 @@ } } + // 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']); + } + // Check if an alias with specified address already exists - if (User::aliasExists($email)) { + 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()) { @@ -748,11 +742,6 @@ } } - // Check if a group with specified address already exists - if (Group::emailExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'alias']); - } - return null; } diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php --- a/src/app/Http/Controllers/RelationController.php +++ b/src/app/Http/Controllers/RelationController.php @@ -308,6 +308,10 @@ $response['config'] = $resource->getConfig(); } + if (method_exists($resource, 'aliases')) { + $response['aliases'] = $resource->aliases()->pluck('alias')->all(); + } + return response()->json($response); } diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php --- a/src/app/Observers/ResourceObserver.php +++ b/src/app/Observers/ResourceObserver.php @@ -15,18 +15,6 @@ */ public function creating(Resource $resource): void { - if (empty($resource->email)) { - if (!isset($resource->domain)) { - throw new \Exception("Missing 'domain' property for a new resource"); - } - - $domainName = \strtolower($resource->domain); - - $resource->email = "resource-{$resource->id}@{$domainName}"; - } else { - $resource->email = \strtolower($resource->email); - } - $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE; } diff --git a/src/app/Observers/SharedFolderAliasObserver.php b/src/app/Observers/SharedFolderAliasObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SharedFolderAliasObserver.php @@ -0,0 +1,82 @@ +alias = \strtolower($alias->alias); + + $domainName = explode('@', $alias->alias)[1]; + + $domain = Domain::where('namespace', $domainName)->first(); + + if (!$domain) { + \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); + return false; + } + + if ($alias->sharedFolder) { + if ($alias->sharedFolder->tenant_id != $domain->tenant_id) { + \Log::error("Reseller for folder '{$alias->sharedFolder->email}' and domain '{$domainName}' differ."); + return false; + } + } + + return true; + } + + /** + * Handle the shared folder alias "created" event. + * + * @param \App\SharedFolderAlias $alias Shared folder email alias + * + * @return void + */ + public function created(SharedFolderAlias $alias) + { + if ($alias->sharedFolder) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); + } + } + + /** + * Handle the shared folder alias "updated" event. + * + * @param \App\SharedFolderAlias $alias Shared folder email alias + * + * @return void + */ + public function updated(SharedFolderAlias $alias) + { + if ($alias->sharedFolder) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); + } + } + + /** + * Handle the shared folder alias "deleted" event. + * + * @param \App\SharedFolderAlias $alias Shared folder email alias + * + * @return void + */ + public function deleted(SharedFolderAlias $alias) + { + if ($alias->sharedFolder) { + \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); + } + } +} diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php --- a/src/app/Observers/SharedFolderObserver.php +++ b/src/app/Observers/SharedFolderObserver.php @@ -19,18 +19,6 @@ $folder->type = 'mail'; } - if (empty($folder->email)) { - if (!isset($folder->domain)) { - throw new \Exception("Missing 'domain' property for a new shared folder"); - } - - $domainName = \strtolower($folder->domain); - - $folder->email = "{$folder->type}-{$folder->id}@{$domainName}"; - } else { - $folder->email = \strtolower($folder->email); - } - $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; } diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -12,8 +12,6 @@ /** * Handle the "creating" event on an alias * - * Ensures that there's no user with specified email. - * * @param \App\UserAlias $alias The user email alias * * @return bool @@ -60,7 +58,7 @@ } /** - * Handle the user setting "updated" event. + * Handle the user alias "updated" event. * * @param \App\UserAlias $alias User email alias * @@ -74,7 +72,7 @@ } /** - * Handle the user setting "deleted" event. + * Handle the user alias "deleted" event. * * @param \App\UserAlias $alias User email alias * diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -54,6 +54,7 @@ \App\Resource::observe(\App\Observers\ResourceObserver::class); \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class); + \App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::class); \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); diff --git a/src/app/Resource.php b/src/app/Resource.php --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -4,6 +4,7 @@ use App\Traits\BelongsToTenantTrait; use App\Traits\EntitleableTrait; +use App\Traits\EmailPropertyTrait; use App\Traits\ResourceConfigTrait; use App\Traits\SettingsTrait; use App\Traits\StatusPropertyTrait; @@ -30,6 +31,7 @@ use SoftDeletes; use StatusPropertyTrait; use UuidIntKeyTrait; + use EmailPropertyTrait; // must be first after UuidIntKeyTrait // we've simply never heard of this resource public const STATUS_NEW = 1 << 0; @@ -44,54 +46,12 @@ // resource has been created in IMAP public const STATUS_IMAP_READY = 1 << 8; + // A template for the email attribute on a resource creation + public const EMAIL_TEMPLATE = 'resource-{id}@{domainName}'; + protected $fillable = [ 'email', 'name', 'status', ]; - - /** @var ?string Domain name for a resource to be created */ - public $domain; - - - /** - * Returns the resource domain. - * - * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist - */ - public function domain(): ?Domain - { - if (isset($this->domain)) { - $domainName = $this->domain; - } else { - list($local, $domainName) = explode('@', $this->email); - } - - return Domain::where('namespace', $domainName)->first(); - } - - /** - * Find whether an email address exists as a resource (including deleted resources). - * - * @param string $email Email address - * @param bool $return_resource Return Resource instance instead of boolean - * - * @return \App\Resource|bool True or Resource model object if found, False otherwise - */ - public static function emailExists(string $email, bool $return_resource = false) - { - if (strpos($email, '@') === false) { - return false; - } - - $email = \strtolower($email); - - $resource = self::withTrashed()->where('email', $email)->first(); - - if ($resource) { - return $return_resource ? $resource : true; - } - - return false; - } } diff --git a/src/app/SharedFolder.php b/src/app/SharedFolder.php --- a/src/app/SharedFolder.php +++ b/src/app/SharedFolder.php @@ -2,13 +2,14 @@ namespace App; +use App\Traits\AliasesTrait; use App\Traits\BelongsToTenantTrait; use App\Traits\EntitleableTrait; +use App\Traits\EmailPropertyTrait; use App\Traits\SharedFolderConfigTrait; use App\Traits\SettingsTrait; use App\Traits\StatusPropertyTrait; use App\Traits\UuidIntKeyTrait; -use App\Wallet; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -24,6 +25,7 @@ */ class SharedFolder extends Model { + use AliasesTrait; use BelongsToTenantTrait; use EntitleableTrait; use SharedFolderConfigTrait; @@ -31,6 +33,7 @@ use SoftDeletes; use StatusPropertyTrait; use UuidIntKeyTrait; + use EmailPropertyTrait; // must be first after UuidIntKeyTrait // we've simply never heard of this folder public const STATUS_NEW = 1 << 0; @@ -48,6 +51,9 @@ /** @const array Supported folder type labels */ public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note', 'file']; + /** @const string A template for the email attribute on a folder creation */ + public const EMAIL_TEMPLATE = '{type}-{id}@{domainName}'; + /** @var array Mass-assignable properties */ protected $fillable = [ 'email', @@ -56,51 +62,6 @@ 'type', ]; - /** @var ?string Domain name for a shared folder to be created */ - public $domain; - - - /** - * Returns the shared folder domain. - * - * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist - */ - public function domain(): ?Domain - { - if (isset($this->domain)) { - $domainName = $this->domain; - } else { - list($local, $domainName) = explode('@', $this->email); - } - - return Domain::where('namespace', $domainName)->first(); - } - - /** - * Find whether an email address exists as a shared folder (including deleted folders). - * - * @param string $email Email address - * @param bool $return_folder Return SharedFolder instance instead of boolean - * - * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise - */ - public static function emailExists(string $email, bool $return_folder = false) - { - if (strpos($email, '@') === false) { - return false; - } - - $email = \strtolower($email); - - $folder = self::withTrashed()->where('email', $email)->first(); - - if ($folder) { - return $return_folder ? $folder : true; - } - - return false; - } - /** * Folder type mutator * diff --git a/src/app/SharedFolderAlias.php b/src/app/SharedFolderAlias.php new file mode 100644 --- /dev/null +++ b/src/app/SharedFolderAlias.php @@ -0,0 +1,39 @@ +attributes['alias'] = \strtolower($alias); + } + + /** + * The shared folder to which this alias belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function sharedFolder() + { + return $this->belongsTo('\App\SharedFolder', 'shared_folder_id', 'id'); + } +} diff --git a/src/app/Traits/UserAliasesTrait.php b/src/app/Traits/AliasesTrait.php rename from src/app/Traits/UserAliasesTrait.php rename to src/app/Traits/AliasesTrait.php --- a/src/app/Traits/UserAliasesTrait.php +++ b/src/app/Traits/AliasesTrait.php @@ -2,11 +2,22 @@ namespace App\Traits; -trait UserAliasesTrait +use Illuminate\Support\Str; + +trait AliasesTrait { /** + * Email aliases of this object. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function aliases() + { + return $this->hasMany(static::class . 'Alias'); + } + + /** * Find whether an email address exists as an alias - * (including aliases of deleted users). * * @param string $email Email address * @@ -19,14 +30,13 @@ } $email = \strtolower($email); + $class = static::class . 'Alias'; - $count = \App\UserAlias::where('alias', $email)->count(); - - return $count > 0; + return $class::where('alias', $email)->count() > 0; } /** - * A helper to update user aliases list. + * A helper to update object's aliases list. * * Example Usage: * @@ -47,6 +57,7 @@ $existing_aliases = []; foreach ($this->aliases()->get() as $alias) { + /** @var \App\UserAlias|\App\SharedFolderAlias $alias */ if (!in_array($alias->alias, $aliases)) { $alias->delete(); } else { diff --git a/src/app/Traits/EmailPropertyTrait.php b/src/app/Traits/EmailPropertyTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/EmailPropertyTrait.php @@ -0,0 +1,92 @@ +email) && defined('static::EMAIL_TEMPLATE')) { + $template = static::EMAIL_TEMPLATE; // @phpstan-ignore-line + $defaults = [ + 'type' => 'mail', + ]; + + foreach (['id', 'domainName', 'type'] as $prop) { + if (strpos($template, "{{$prop}}") === false) { + continue; + } + + $value = $model->{$prop} ?? ($defaults[$prop] ?? ''); + + if ($value === '' || $value === null) { + throw new \Exception("Missing '{$prop}' property for " . static::class); + } + + $template = str_replace("{{$prop}}", $value, $template); + } + + $model->email = strtolower($template); + } + }); + } + + /** + * Returns the object's domain (including soft-deleted). + * + * @return ?\App\Domain The domain to which the object belongs to, NULL if it does not exist + */ + public function domain(): ?\App\Domain + { + if (empty($this->email) && isset($this->domainName)) { + $domainName = $this->domainName; + } else { + list($local, $domainName) = explode('@', $this->email); + } + + return \App\Domain::withTrashed()->where('namespace', $domainName)->first(); + } + + /** + * Find whether an email address exists as a model object (including soft-deleted). + * + * @param string $email Email address + * @param bool $return_object Return model instance instead of a boolean + * + * @return object|bool True or Model object if found, False otherwise + */ + public static function emailExists(string $email, bool $return_object = false) + { + if (strpos($email, '@') === false) { + return false; + } + + $email = \strtolower($email); + + $object = static::withTrashed()->where('email', $email)->first(); + + if ($object) { + return $return_object ? $object : true; + } + + return false; + } + + /** + * Ensure the email is appropriately cased. + * + * @param string $email Email address + */ + public function setEmailAttribute(string $email): void + { + $this->attributes['email'] = strtolower($email); + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -2,15 +2,14 @@ namespace App; -use App\UserAlias; +use App\Traits\AliasesTrait; use App\Traits\BelongsToTenantTrait; use App\Traits\EntitleableTrait; -use App\Traits\UserAliasesTrait; +use App\Traits\EmailPropertyTrait; use App\Traits\UserConfigTrait; use App\Traits\UuidIntKeyTrait; use App\Traits\SettingsTrait; use App\Traits\StatusPropertyTrait; -use App\Wallet; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -31,12 +30,13 @@ */ class User extends Authenticatable { + use AliasesTrait; use BelongsToTenantTrait; use EntitleableTrait; + use EmailPropertyTrait; use HasApiTokens; use NullableFields; use UserConfigTrait; - use UserAliasesTrait; use UuidIntKeyTrait; use SettingsTrait; use SoftDeletes; @@ -104,16 +104,6 @@ } /** - * Email aliases of this user. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function aliases() - { - return $this->hasMany('App\UserAlias', 'user_id'); - } - - /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. @@ -264,20 +254,6 @@ } /** - * Return the \App\Domain for this user. - * - * @return \App\Domain|null - */ - public function domain() - { - list($local, $domainName) = explode('@', $this->email); - - $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); - - return $domain; - } - - /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets @@ -307,31 +283,6 @@ } /** - * Find whether an email address exists as a user (including deleted users). - * - * @param string $email Email address - * @param bool $return_user Return User instance instead of boolean - * - * @return \App\User|bool True or User model object if found, False otherwise - */ - public static function emailExists(string $email, bool $return_user = false) - { - if (strpos($email, '@') === false) { - return false; - } - - $email = \strtolower($email); - - $user = self::withTrashed()->where('email', $email)->first(); - - if ($user) { - return $return_user ? $user : true; - } - - return false; - } - - /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class @@ -386,7 +337,7 @@ return $user; } - $aliases = UserAlias::where('alias', $email)->get(); + $aliases = \App\UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; diff --git a/src/app/UserAlias.php b/src/app/UserAlias.php --- a/src/app/UserAlias.php +++ b/src/app/UserAlias.php @@ -18,6 +18,16 @@ ]; /** + * Ensure the email address is appropriately cased. + * + * @param string $alias Email address + */ + public function setAliasAttribute(string $alias) + { + $this->attributes['alias'] = \strtolower($alias); + } + + /** * The user to which this alias belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo diff --git a/src/database/migrations/2022_01_25_100000_create_shared_folder_aliases_table.php b/src/database/migrations/2022_01_25_100000_create_shared_folder_aliases_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_01_25_100000_create_shared_folder_aliases_table.php @@ -0,0 +1,43 @@ +bigIncrements('id'); + $table->unsignedBigInteger('shared_folder_id'); + $table->string('alias'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent(); + + $table->unique(['alias', 'shared_folder_id']); + + $table->foreign('shared_folder_id')->references('id')->on('shared_folders') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('shared_folder_aliases'); + } +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -494,7 +494,7 @@ } }) - form.find('.is-invalid:not(.listinput-widget)').first().focus() + form.find('.is-invalid:not(.list-input)').first().focus() }) } else if (data.status == 'error') { diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -124,6 +124,7 @@ 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", + 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", @@ -317,6 +318,7 @@ ], 'shf' => [ + 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue --- a/src/resources/vue/Admin/SharedFolder.vue +++ b/src/resources/vue/Admin/SharedFolder.vue @@ -43,6 +43,11 @@ {{ $t('form.settings') }} +
@@ -66,6 +71,29 @@
+
+
+
+ + + + + + + + + + + + + + + + +
{{ $t('form.email') }}
{{ alias }}
{{ $t('shf.aliases-none') }}
+
+
+
@@ -74,7 +102,7 @@ export default { data() { return { - folder: { config: {} } + folder: { config: {}, aliases: [] } } }, created() { diff --git a/src/resources/vue/SharedFolder/Info.vue b/src/resources/vue/SharedFolder/Info.vue --- a/src/resources/vue/SharedFolder/Info.vue +++ b/src/resources/vue/SharedFolder/Info.vue @@ -53,10 +53,10 @@ -
- +
+
- +
{{ $t('btn.submit') }} @@ -85,18 +85,20 @@