diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -21,13 +21,50 @@ $folders = $imap->listMailboxes('', '*'); + $imap->closeConnection(); + if (!is_array($folders)) { throw new \Exception("Failed to get IMAP folders"); } + return count($folders) > 0; + } + + /** + * Check if a shared folder is set up. + * + * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld + * + * @return bool True if a folder exists and is set up, False otherwise + */ + public static function verifySharedFolder(string $folder): bool + { + $config = self::getConfig(); + $imap = self::initIMAP($config); + + // Convert the folder from UTF8 to UTF7-IMAP + if (\preg_match('|^(shared/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) { + $folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8'); + $folder = $matches[1] . $folderName . $matches[3]; + } + + // FIXME: just listMailboxes() does not return shared folders at all + + $metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']); + $imap->closeConnection(); - return count($folders) > 0; + // Note: We have to use error code to distinguish an error from "no mailbox" response + + if ($imap->errornum === \rcube_imap_generic::ERROR_NO) { + return false; + } + + if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) { + throw new \Exception("Failed to get folder metadata from IMAP"); + } + + return true; } /** 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 @@ -4,6 +4,7 @@ use App\Domain; use App\Group; +use App\Resource; use App\User; class LDAP @@ -13,6 +14,12 @@ 'sender_policy', ]; + /** @const array Resource settings used by the backend */ + public const RESOURCE_SETTINGS = [ + 'folder', + 'invitation_policy', + ]; + /** @const array User settings used by the backend */ public const USER_SETTINGS = [ 'first_name', @@ -249,6 +256,47 @@ } /** + * Create a resource in LDAP. + * + * @param \App\Resource $resource The resource to create. + * + * @throws \Exception + */ + public static function createResource(Resource $resource): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $domainName = explode('@', $resource->email, 2)[1]; + $cn = $ldap->quote_string($resource->name); + $dn = "cn={$cn}," . self::baseDN($domainName, 'Resources'); + + $entry = [ + 'mail' => $resource->email, + 'objectclass' => [ + 'top', + 'kolabresource', + 'kolabsharedfolder', + 'mailrecipient', + ], + 'kolabfoldertype' => 'event', + ]; + + self::setResourceAttributes($ldap, $resource, $entry); + + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" + ); + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + + /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should @@ -381,6 +429,34 @@ } /** + * Delete a resource from LDAP. + * + * @param \App\Resource $resource The resource to delete. + * + * @throws \Exception + */ + public static function deleteResource(Resource $resource): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + if (self::getResourceEntry($ldap, $resource->email, $dn)) { + $result = $ldap->delete_entry($dn); + + if (!$result) { + self::throwException( + $ldap, + "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" + ); + } + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + + /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. @@ -457,6 +533,28 @@ } /** + * Get a resource data from LDAP. + * + * @param string $email The resource email. + * + * @return array|false|null + * @throws \Exception + */ + public static function getResource(string $email) + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $resource = self::getResourceEntry($ldap, $email, $dn); + + if (empty(self::$ldap)) { + $ldap->close(); + } + + return $resource; + } + + /** * Get a user data from LDAP. * * @param string $email The user email. @@ -560,6 +658,43 @@ } /** + * Update a resource in LDAP. + * + * @param \App\Resource $resource The resource to update + * + * @throws \Exception + */ + public static function updateResource(Resource $resource): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); + + if (empty($oldEntry)) { + self::throwException( + $ldap, + "Failed to update resource {$resource->email} in LDAP (resource not found)" + ); + } + + self::setResourceAttributes($ldap, $resource, $newEntry); + + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException( + $ldap, + "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" + ); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + + /** * Update a user in LDAP. * * @param \App\User $user The user account to update. @@ -705,6 +840,53 @@ } /** + * Set common resource attributes + */ + private static function setResourceAttributes($ldap, Resource $resource, &$entry) + { + $entry['cn'] = $resource->name; + $entry['owner'] = null; + $entry['kolabinvitationpolicy'] = null; + + $settings = $resource->getSettings(['invitation_policy', 'folder']); + + $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; + + // Here's how Wallace's resources module works: + // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, + // and mail sent to the owner to accept/decline the request. + // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, + // event saved, and notification (not confirmation) mail sent to the owner. + // - if there's no owner (policy irrelevant): an accept response is sent, event saved. + // - if policy is ACT_REJECT: a decline response is sent + // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. + // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically + // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). + // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), + // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. + + // For now we ignore the notifications feature + + if (!empty($settings['invitation_policy'])) { + if ($settings['invitation_policy'] === 'accept') { + $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; + } elseif ($settings['invitation_policy'] === 'reject') { + $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; + } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { + if (self::getUserEntry($ldap, $m[1], $userDN)) { + $entry['owner'] = $userDN; + $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; + } else { + $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; + } + + // TODO: Set folder ACL so the owner can write to it + // TODO: Do we need to add lrs for anyone? + } + } + } + + /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) @@ -807,7 +989,7 @@ * @param string $email Group email (mail) * @param string $dn Reference to group DN * - * @return false|null|array Group entry, False on error, NULL if not found + * @return null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { @@ -819,18 +1001,30 @@ // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. - $result = $ldap->search($base_dn, "(mail=$email)", "sub", $attrs); + return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); + } - if ($result && $result->count() == 1) { - $entries = $result->entries(true); - $dn = key($entries); - $entry = $entries[$dn]; - $entry['dn'] = $dn; + /** + * Get a resource entry from LDAP. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $email Resource email (mail) + * @param string $dn Reference to the resource DN + * + * @return null|array Resource entry, NULL if not found + */ + private static function getResourceEntry($ldap, $email, &$dn = null) + { + $domainName = explode('@', $email, 2)[1]; + $base_dn = self::baseDN($domainName, 'Resources'); - return $entry; - } + $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', + 'kolabfoldertype', 'kolabinvitationpolicy', 'owner']; - return null; + // For resources we're using search() instead of get_entry() because + // a resource name is not constant, so e.g. on update we might have + // the new name, but not the old one. Email address is constant. + return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** @@ -951,6 +1145,33 @@ } /** + * Find a single entry in LDAP by using search. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $base_dn Base DN + * @param string $filter Search filter + * @param array $attrs Result attributes + * @param string $dn Reference to a DN of the found entry + * + * @return null|array LDAP entry, NULL if not found + */ + private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) + { + $result = $ldap->search($base_dn, $filter, 'sub', $attrs); + + if ($result && $result->count() == 1) { + $entries = $result->entries(true); + $dn = key($entries); + $entry = $entries[$dn]; + $entry['dn'] = $dn; + + return $entry; + } + + return null; + } + + /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -107,6 +107,7 @@ \App\Group::class, \App\Package::class, \App\Plan::class, + \App\Resource::class, \App\Sku::class, \App\User::class, ]; @@ -133,6 +134,19 @@ } /** + * Find a resource. + * + * @param string $resource Resource ID or email + * @param bool $withDeleted Include deleted + * + * @return \App\Resource|null + */ + public function getResource($resource, $withDeleted = false) + { + return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); + } + + /** * Find the user. * * @param string $user User ID or email diff --git a/src/app/Console/Commands/Resource/VerifyCommand.php b/src/app/Console/Commands/Resource/VerifyCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Resource/VerifyCommand.php @@ -0,0 +1,42 @@ +getResource($this->argument('resource')); + + if (!$resource) { + $this->error("Resource not found."); + return 1; + } + + $job = new \App\Jobs\Resource\VerifyJob($resource->id); + $job->handle(); + + // TODO: We should check the job result and print an error on failure + } +} diff --git a/src/app/Console/Commands/ResourcesCommand.php b/src/app/Console/Commands/ResourcesCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/ResourcesCommand.php @@ -0,0 +1,12 @@ +exists() || \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() ); } diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Beta/Resources.php @@ -0,0 +1,49 @@ +wallet()->entitlements() + ->where('entitleable_type', \App\Domain::class)->count() > 0; + } + + return false; + } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } +} diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -11,7 +11,6 @@ */ public static function entitleableClass(): string { - // TODO - return ''; + return \App\Resource::class; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php --- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php @@ -21,14 +21,7 @@ if ($owner) { if ($owner = User::find($owner)) { - foreach ($owner->wallets as $wallet) { - $wallet->entitlements()->where('entitleable_type', Group::class)->get() - ->each(function ($entitlement) use ($result) { - $result->push($entitlement->entitleable); - }); - } - - $result = $result->sortBy('name')->values(); + $result = $owner->groups(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($group = Group::where('email', $search)->first()) { diff --git a/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php @@ -0,0 +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)]), + ]; + + 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/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 @@ -3,11 +3,8 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Domain; -use App\Group; use App\Sku; use App\User; -use App\UserAlias; -use App\UserSetting; use App\Wallet; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; @@ -51,19 +48,21 @@ if ($result->isEmpty()) { // Search by an alias - $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); + $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email - $ext_user_ids = UserSetting::where('key', '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 a distribution list email - if ($group = Group::withTrashed()->where('email', $search)->first()) { + // Search by a distribution list or resource email + 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(); } if (!$user_ids->isEmpty()) { diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -13,7 +13,7 @@ class DomainsController extends Controller { /** @var array Common object properties in the API response */ - protected static $objectProps = ['namespace', 'status', 'type']; + protected static $objectProps = ['namespace', 'type']; /** diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -14,7 +14,7 @@ class GroupsController extends Controller { /** @var array Common object properties in the API response */ - protected static $objectProps = ['email', 'name', 'status']; + protected static $objectProps = ['email', 'name']; /** diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php --- a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php @@ -20,14 +20,7 @@ if ($owner) { if ($owner = User::withSubjectTenantContext()->find($owner)) { - foreach ($owner->wallets as $wallet) { - $wallet->entitlements()->where('entitleable_type', Group::class)->get() - ->each(function ($entitlement) use ($result) { - $result->push($entitlement->entitleable); - }); - } - - $result = $result->sortBy('name')->values(); + $result = $owner->groups(false)->orderBy('name')->get(); } } elseif (!empty($search)) { if ($group = Group::withSubjectTenantContext()->where('email', $search)->first()) { diff --git a/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php @@ -0,0 +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)]), + ]; + + return response()->json($result); + } +} diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -0,0 +1,353 @@ +errorResponse(404); + } + + /** + * Delete a resource. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + $resource = Resource::find($id); + + if (!$this->checkTenant($resource)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canDelete($resource)) { + return $this->errorResponse(403); + } + + $resource->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.resource-delete-success'), + ]); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Listing of resources belonging to the authenticated user. + * + * The resource-entitlements billed to the current user wallet(s) + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + + $result = $user->resources()->orderBy('name')->get() + ->map(function (Resource $resource) { + return $this->objectToClient($resource); + }); + + return response()->json($result); + } + + /** + * Set the resource configuration. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setConfig($id) + { + $resource = Resource::find($id); + + if (!$this->checkTenant($resource)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canUpdate($resource)) { + return $this->errorResponse(403); + } + + $errors = $resource->setConfig(request()->input()); + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.resource-setconfig-success'), + ]); + } + + /** + * Display information of a resource specified by $id. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $resource = Resource::find($id); + + if (!$this->checkTenant($resource)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($resource)) { + return $this->errorResponse(403); + } + + $response = $this->objectToClient($resource, true); + + $response['statusInfo'] = self::statusInfo($resource); + + // Resource configuration, e.g. invitation_policy + $response['config'] = $resource->getConfig(); + + return response()->json($response); + } + + /** + * Fetch resource status (and reload setup process) + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $resource = Resource::find($id); + + if (!$this->checkTenant($resource)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($resource)) { + return $this->errorResponse(403); + } + + $response = $this->processStateUpdate($resource); + $response = array_merge($response, self::objectState($resource)); + + return response()->json($response); + } + + /** + * Resource status (extended) information + * + * @param \App\Resource $resource Resource object + * + * @return array Status information + */ + public static function statusInfo(Resource $resource): array + { + return self::processStateInfo( + $resource, + [ + 'resource-new' => 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()) { + $errors = $v->errors()->toArray(); + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + // Create the resource + $resource = new Resource(); + $resource->name = request()->input('name'); + $resource->domain = $domain; + $resource->save(); + + $resource->assignToWallet($owner->wallets->first()); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => \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); + } + + $resource->save(); + + return response()->json([ + 'status' => 'success', + 'message' => \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': + // Resource not in LDAP, create it + $job = new \App\Jobs\Resource\CreateJob($resource->id); + $job->handle(); + + $resource->refresh(); + + return $resource->isLdapReady(); + + case 'resource-imap-ready': + // Resource not in IMAP? Verify again + // Do it synchronously if the imap admin credentials are available + // otherwise let the worker do the job + if (!\config('imap.admin_password')) { + \App\Jobs\Resource\VerifyJob::dispatch($resource->id); + + return null; + } + + $job = new \App\Jobs\Resource\VerifyJob($resource->id); + $job->handle(); + + $resource->refresh(); + + return $resource->isImapReady(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } + + /** + * Prepare resource statuses for the UI + * + * @param \App\Resource $resource Resource object + * + * @return array Statuses array + */ + protected static function objectState(Resource $resource): array + { + return [ + 'isLdapReady' => $resource->isLdapReady(), + 'isImapReady' => $resource->isImapReady(), + 'isActive' => $resource->isActive(), + 'isDeleted' => $resource->isDeleted() || $resource->trashed(), + ]; + } +} 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 @@ -37,7 +37,7 @@ protected $deleteBeforeCreate; /** @var array Common object properties in the API response */ - protected static $objectProps = ['email', 'status']; + protected static $objectProps = ['email']; /** @@ -252,6 +252,8 @@ 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), + // TODO: Make 'enableResources' working for wallet controllers that aren't account owners + 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, ]; @@ -718,12 +720,15 @@ return \trans('validation.entryexists', ['attribute' => 'email']); } - // Check if a group with specified address already exists - if ($existing_group = Group::emailExists($email, true)) { - // If this is a deleted group in the same custom domain + // Check if a group or resource with specified address already exists + if ( + ($existing = Group::emailExists($email, true)) + || ($existing = \App\Resource::emailExists($email, true)) + ) { + // If this is a deleted group/resource in the same custom domain // we'll force delete it before - if (!$domain->isPublic() && $existing_group->trashed()) { - $deleted = $existing_group; + if (!$domain->isPublic() && $existing->trashed()) { + $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } diff --git a/src/app/Jobs/Resource/CreateJob.php b/src/app/Jobs/Resource/CreateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/CreateJob.php @@ -0,0 +1,61 @@ +getResource(); + + if (!$resource) { + return; + } + + // sanity checks + if ($resource->isDeleted()) { + $this->fail(new \Exception("Resource {$this->resourceId} is marked as deleted.")); + return; + } + + if ($resource->trashed()) { + $this->fail(new \Exception("Resource {$this->resourceId} is actually deleted.")); + return; + } + + if ($resource->isLdapReady()) { + $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready.")); + return; + } + + // see if the domain is ready + $domain = $resource->domain(); + + if (!$domain) { + $this->fail(new \Exception("The domain for resource {$this->resourceId} does not exist.")); + return; + } + + if ($domain->isDeleted()) { + $this->fail(new \Exception("The domain for resource {$this->resourceId} is marked as deleted.")); + return; + } + + if (!$domain->isLdapReady()) { + $this->release(60); + return; + } + + \App\Backends\LDAP::createResource($resource); + + $resource->status |= \App\Resource::STATUS_LDAP_READY; + $resource->save(); + } +} diff --git a/src/app/Jobs/Resource/DeleteJob.php b/src/app/Jobs/Resource/DeleteJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/DeleteJob.php @@ -0,0 +1,42 @@ +getResource(); + + if (!$resource) { + return; + } + + // sanity checks + if ($resource->isDeleted()) { + $this->fail(new \Exception("Resource {$this->resourceId} is already marked as deleted.")); + return; + } + + \App\Backends\LDAP::deleteResource($resource); + + $resource->status |= \App\Resource::STATUS_DELETED; + + if ($resource->isLdapReady()) { + $resource->status ^= \App\Resource::STATUS_LDAP_READY; + } + + if ($resource->isImapReady()) { + $resource->status ^= \App\Resource::STATUS_IMAP_READY; + } + + $resource->save(); + } +} diff --git a/src/app/Jobs/Resource/UpdateJob.php b/src/app/Jobs/Resource/UpdateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/UpdateJob.php @@ -0,0 +1,30 @@ +getResource(); + + if (!$resource) { + return; + } + + // Cancel the update if the resource is deleted or not yet in LDAP + if (!$resource->isLdapReady() || $resource->isDeleted()) { + $this->delete(); + return; + } + + \App\Backends\LDAP::updateResource($resource); + } +} diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/VerifyJob.php @@ -0,0 +1,36 @@ +getResource(); + + if (!$resource) { + return; + } + + // the user has a mailbox (or is marked as such) + if ($resource->isImapReady()) { + $this->fail(new \Exception("Resource {$this->resourceId} is already verified.")); + return; + } + + $folder = $resource->getSetting('folder'); + + if ($folder && \App\Backends\IMAP::verifySharedFolder($folder)) { + $resource->status |= \App\Resource::STATUS_IMAP_READY; + $resource->status |= \App\Resource::STATUS_ACTIVE; + $resource->save(); + } + } +} diff --git a/src/app/Jobs/ResourceJob.php b/src/app/Jobs/ResourceJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/ResourceJob.php @@ -0,0 +1,73 @@ +handle(); + * ``` + */ +abstract class ResourceJob extends CommonJob +{ + /** + * The ID for the \App\Resource. This is the shortest globally unique identifier and saves Redis space + * compared to a serialized version of the complete \App\Resource object. + * + * @var int + */ + protected $resourceId; + + /** + * The \App\Resource email property, for legibility in the queue management. + * + * @var string + */ + protected $resourceEmail; + + /** + * Create a new job instance. + * + * @param int $resourceId The ID for the resource to process. + * + * @return void + */ + public function __construct(int $resourceId) + { + $this->resourceId = $resourceId; + + $resource = $this->getResource(); + + if ($resource) { + $this->resourceEmail = $resource->email; + } + } + + /** + * Get the \App\Resource entry associated with this job. + * + * @return \App\Resource|null + * + * @throws \Exception + */ + protected function getResource() + { + $resource = \App\Resource::withTrashed()->find($this->resourceId); + + if (!$resource) { + // The record might not exist yet in case of a db replication environment + // This will release the job and delay another attempt for 5 seconds + if ($this instanceof Resource\CreateJob) { + $this->release(5); + return null; + } + + $this->fail(new \Exception("Resource {$this->resourceId} could not be found in the database.")); + } + + return $resource; + } +} diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/ResourceObserver.php @@ -0,0 +1,136 @@ +email)) { + if (!isset($resource->name)) { + 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; + } + + /** + * Handle the resource "created" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function created(Resource $resource) + { + $domainName = explode('@', $resource->email, 2)[1]; + + $settings = [ + 'folder' => "shared/Resources/{$resource->name}@{$domainName}", + ]; + + foreach ($settings as $key => $value) { + $settings[$key] = [ + 'key' => $key, + 'value' => $value, + 'resource_id' => $resource->id, + ]; + } + + // Note: Don't use setSettings() here to bypass ResourceSetting observers + // Note: This is a single multi-insert query + $resource->settings()->insert(array_values($settings)); + + // Create resource record in LDAP, then check if it is created in IMAP + $chain = [ + new \App\Jobs\Resource\VerifyJob($resource->id), + ]; + + \App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id); + } + + /** + * Handle the resource "deleting" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function deleting(Resource $resource) + { + // Entitlements do not have referential integrity on the entitled object, so this is our + // way of doing an onDelete('cascade') without the foreign key. + \App\Entitlement::where('entitleable_id', $resource->id) + ->where('entitleable_type', Resource::class) + ->delete(); + } + + /** + * Handle the resource "deleted" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function deleted(Resource $resource) + { + if ($resource->isForceDeleting()) { + return; + } + + \App\Jobs\Resource\DeleteJob::dispatch($resource->id); + } + + /** + * Handle the resource "updated" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function updated(Resource $resource) + { + \App\Jobs\Resource\UpdateJob::dispatch($resource->id); + + // Update the folder property if name changed + if ($resource->name != $resource->getOriginal('name')) { + $domainName = explode('@', $resource->email, 2)[1]; + $folder = "shared/Resources/{$resource->name}@{$domainName}"; + + // Note: This does not invoke ResourceSetting observer events, good. + $resource->settings()->where('key', 'folder')->update(['value' => $folder]); + } + } + + /** + * Handle the resource "force deleted" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function forceDeleted(Resource $resource) + { + // A group can be force-deleted separately from the owner + // we have to force-delete entitlements + \App\Entitlement::where('entitleable_id', $resource->id) + ->where('entitleable_type', Resource::class) + ->forceDelete(); + } +} diff --git a/src/app/Observers/ResourceSettingObserver.php b/src/app/Observers/ResourceSettingObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/ResourceSettingObserver.php @@ -0,0 +1,51 @@ +key, LDAP::RESOURCE_SETTINGS)) { + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + } + } + + /** + * Handle the resource setting "updated" event. + * + * @param \App\ResourceSetting $resourceSetting Settings object + * + * @return void + */ + public function updated(ResourceSetting $resourceSetting) + { + if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + } + } + + /** + * Handle the resource setting "deleted" event. + * + * @param \App\ResourceSetting $resourceSetting Settings object + * + * @return void + */ + public function deleted(ResourceSetting $resourceSetting) + { + if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) { + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + } + } +} diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -5,6 +5,7 @@ use App\Entitlement; use App\Domain; use App\Group; +use App\Resource; use App\Transaction; use App\User; use App\Wallet; @@ -147,6 +148,7 @@ $users = []; $domains = []; $groups = []; + $resources = []; $entitlements = []; foreach ($assignments as $entitlement) { @@ -156,6 +158,8 @@ $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == Resource::class) { + $resources[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement; } @@ -181,6 +185,12 @@ } } + if (!empty($resources)) { + foreach (Resource::whereIn('id', array_unique($resources))->get() as $_resource) { + $_resource->delete(); + } + } + foreach ($entitlements as $entitlement) { $entitlement->delete(); } @@ -211,6 +221,7 @@ $entitlements = []; $domains = []; $groups = []; + $resources = []; $users = []; foreach ($assignments as $entitlement) { @@ -225,6 +236,8 @@ $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == Resource::class) { + $resources[] = $entitlement->entitleable_id; } } @@ -252,6 +265,11 @@ Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); } + // Resources can be just removed + if (!empty($resources)) { + Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete(); + } + // Remove transactions, they also have no foreign key constraint Transaction::where('object_type', Entitlement::class) ->whereIn('object_id', $entitlements) 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 @@ -51,6 +51,8 @@ \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); + \App\Resource::observe(\App\Observers\ResourceObserver::class); + \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); diff --git a/src/app/Resource.php b/src/app/Resource.php new file mode 100644 --- /dev/null +++ b/src/app/Resource.php @@ -0,0 +1,210 @@ +id)) { + throw new \Exception("Resource not yet exists"); + } + + if ($this->entitlements()->count()) { + throw new \Exception("Resource already assigned to a wallet"); + } + + $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first(); + $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); + + \App\Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, + 'entitleable_id' => $this->id, + 'entitleable_type' => Resource::class + ]); + + return $this; + } + + /** + * 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; + } + + /** + * Returns whether this resource is active. + * + * @return bool + */ + public function isActive(): bool + { + return ($this->status & self::STATUS_ACTIVE) > 0; + } + + /** + * Returns whether this resource is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return ($this->status & self::STATUS_DELETED) > 0; + } + + /** + * Returns whether this resource's folder exists in IMAP. + * + * @return bool + */ + public function isImapReady(): bool + { + return ($this->status & self::STATUS_IMAP_READY) > 0; + } + + /** + * Returns whether this resource is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return ($this->status & self::STATUS_LDAP_READY) > 0; + } + + /** + * Returns whether this resource is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Resource status mutator + * + * @throws \Exception + */ + public function setStatusAttribute($status) + { + $new_status = 0; + + $allowed_values = [ + self::STATUS_NEW, + self::STATUS_ACTIVE, + self::STATUS_DELETED, + self::STATUS_IMAP_READY, + self::STATUS_LDAP_READY, + ]; + + foreach ($allowed_values as $value) { + if ($status & $value) { + $new_status |= $value; + $status ^= $value; + } + } + + if ($status > 0) { + throw new \Exception("Invalid resource status: {$status}"); + } + + $this->attributes['status'] = $new_status; + } +} diff --git a/src/app/ResourceSetting.php b/src/app/ResourceSetting.php new file mode 100644 --- /dev/null +++ b/src/app/ResourceSetting.php @@ -0,0 +1,30 @@ +belongsTo(\App\Resource::class, 'resource_id', 'id'); + } +} diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/GroupName.php --- a/src/app/Rules/GroupName.php +++ b/src/app/Rules/GroupName.php @@ -41,7 +41,7 @@ // Check the max length, according to the database column length if (strlen($name) > 191) { - $this->message = \trans('validation.nametoolong'); + $this->message = \trans('validation.max.string', ['max' => 191]); return false; } diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/ResourceName.php copy from src/app/Rules/GroupName.php copy to src/app/Rules/ResourceName.php --- a/src/app/Rules/GroupName.php +++ b/src/app/Rules/ResourceName.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; -class GroupName implements Rule +class ResourceName implements Rule { private $message; private $owner; @@ -28,7 +28,7 @@ * Determine if the validation rule passes. * * @param string $attribute Attribute name - * @param mixed $name The value to validate + * @param mixed $name Resource name input * * @return bool */ @@ -41,15 +41,22 @@ // Check the max length, according to the database column length if (strlen($name) > 191) { - $this->message = \trans('validation.nametoolong'); + $this->message = \trans('validation.max.string', ['max' => 191]); + return false; + } + + // Check if specified domain is belongs to the user + $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all(); + if (!in_array($this->domain, $domains)) { + $this->message = \trans('validation.domaininvalid'); return false; } // Check if the name is unique in the domain - // FIXME: Maybe just using the whole groups table would be faster than groups()? - $exists = $this->owner->groups() - ->where('groups.name', $name) - ->where('groups.email', 'like', '%@' . $this->domain) + // FIXME: Maybe just using the whole resources table would be faster than resources()? + $exists = $this->owner->resources() + ->where('resources.name', $name) + ->where('resources.email', 'like', '%@' . $this->domain) ->exists(); if ($exists) { diff --git a/src/app/Traits/ResourceConfigTrait.php b/src/app/Traits/ResourceConfigTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/ResourceConfigTrait.php @@ -0,0 +1,86 @@ +getSetting('invitation_policy') ?: 'accept'; + + return $config; + } + + /** + * A helper to update a resource configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation errors + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + // validate and save the invitation policy + if ($key === 'invitation_policy') { + $value = (string) $value; + if ($value === 'accept' || $value === 'reject') { + // do nothing + } elseif (preg_match('/^manual:/', $value, $matches)) { + $email = trim(substr($value, 7)); + if ($error = $this->validateInvitationPolicyUser($email)) { + $errors[$key] = $error; + } else { + $value = "manual:$email"; + } + } else { + $errors[$key] = \trans('validation.ipolicy-invalid'); + } + + if (empty($errors[$key])) { + $this->setSetting($key, $value); + } + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } + + /** + * Validate an email address for use as a resource owner (with invitation policy) + * + * @param string $email Email address + * + * @return ?string Error message on validation error + */ + protected function validateInvitationPolicyUser($email): ?string + { + $v = Validator::make(['email' => $email], ['email' => 'required|email']); + + if ($v->fails()) { + return \trans('validation.emailinvalid'); + } + + $user = \App\User::where('email', \strtolower($email))->first(); + + // The user and resource must be in the same wallet + if ($user && ($wallet = $user->wallet())) { + if ($wallet->user_id == $this->wallet()->user_id) { + return null; + } + } + + return \trans('validation.notalocaluser'); + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -309,22 +309,29 @@ /** * List the domains to which this user is entitled. - * Note: Active public domains are also returned (for the user tenant). + * + * @param bool $with_accounts Include domains assigned to wallets + * the current user controls but not owns. + * @param bool $with_public Include active public domains (for the user tenant). * * @return Domain[] List of Domain objects */ - public function domains(): array + public function domains($with_accounts = true, $with_public = true): array { - if ($this->tenant_id) { - $domains = Domain::where('tenant_id', $this->tenant_id); - } else { - $domains = Domain::withEnvTenantContext(); - } + $domains = []; - $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) - ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) - ->get() - ->all(); + if ($with_public) { + if ($this->tenant_id) { + $domains = Domain::where('tenant_id', $this->tenant_id); + } else { + $domains = Domain::withEnvTenantContext(); + } + + $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) + ->get() + ->all(); + } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); @@ -333,10 +340,12 @@ } } - foreach ($this->accounts as $wallet) { - $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); - foreach ($entitlements as $entitlement) { - $domains[] = $entitlement->entitleable; + if ($with_accounts) { + foreach ($this->accounts as $wallet) { + $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); + foreach ($entitlements as $entitlement) { + $domains[] = $entitlement->entitleable; + } } } @@ -404,8 +413,6 @@ return null; } - - /** * Return groups controlled by the current user. * @@ -561,6 +568,29 @@ return $this; } + /** + * Return resources controlled by the current user. + * + * @param bool $with_accounts Include resources assigned to wallets + * the current user controls but not owns. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function resources($with_accounts = true) + { + $wallets = $this->wallets()->pluck('id')->all(); + + if ($with_accounts) { + $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); + } + + return \App\Resource::select(['resources.*', 'entitlements.wallet_id']) + ->distinct() + ->join('entitlements', 'entitlements.entitleable_id', '=', 'resources.id') + ->whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', \App\Resource::class); + } + public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -69,7 +69,7 @@ "Tests\\": "tests/" } }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ diff --git a/src/database/migrations/2021_11_16_100000_create_resources_tables.php b/src/database/migrations/2021_11_16_100000_create_resources_tables.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_11_16_100000_create_resources_tables.php @@ -0,0 +1,80 @@ +unsignedBigInteger('id'); + $table->string('email')->unique(); + $table->string('name'); + $table->smallInteger('status'); + $table->unsignedBigInteger('tenant_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->primary('id'); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + Schema::create( + 'resource_settings', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger('resource_id'); + $table->string('key'); + $table->text('value'); + $table->timestamps(); + + $table->foreign('resource_id')->references('id')->on('resources') + ->onDelete('cascade')->onUpdate('cascade'); + + $table->unique(['resource_id', 'key']); + } + ); + + \App\Sku::where('title', 'resource')->update([ + 'active' => true, + 'cost' => 0, + ]); + + if (!\App\Sku::where('title', 'beta-resources')->first()) { + \App\Sku::create([ + 'title' => 'beta-resources', + 'name' => 'Calendaring resources', + 'description' => 'Access to calendaring resources', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Resources', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('resource_settings'); + Schema::dropIfExists('resources'); + + // there's no need to remove the SKU + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -25,6 +25,7 @@ 'UserSeeder', 'OpenViduRoomSeeder', 'OauthClientSeeder', + 'ResourceSeeder', ]; $env = ucfirst(App::environment()); diff --git a/src/database/seeds/local/ResourceSeeder.php b/src/database/seeds/local/ResourceSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/local/ResourceSeeder.php @@ -0,0 +1,33 @@ +first(); + $wallet = $john->wallets()->first(); + + $resource = Resource::create([ + 'name' => 'Conference Room #1', + 'email' => 'resource-test1@kolab.org', + ]); + $resource->assignToWallet($wallet); + + $resource = Resource::create([ + 'name' => 'Conference Room #2', + 'email' => 'resource-test2@kolab.org', + ]); + $resource->assignToWallet($wallet); + } +} diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -110,7 +110,7 @@ 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', - 'active' => false, + 'active' => true, ] ); @@ -153,7 +153,7 @@ ); // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( @@ -171,7 +171,7 @@ } // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( @@ -189,7 +189,7 @@ } // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( @@ -207,10 +207,10 @@ } // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { - \App\Sku::create( + Sku::create( [ 'title' => 'distlist', 'name' => 'Distribution lists', @@ -224,6 +224,22 @@ ); } + // Check existence because migration might have added this already + $sku = Sku::where(['title' => 'beta-resources', 'tenant_id' => \config('app.tenant_id')])->first(); + + if (!$sku) { + Sku::create([ + 'title' => 'beta-resources', + 'name' => 'Calendaring resources', + 'description' => 'Access to calendaring resources', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Resources', + 'active' => true, + ]); + } + // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -107,10 +107,10 @@ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', - 'cost' => 101, + 'cost' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', - 'active' => false, + 'active' => true, ] ); @@ -153,7 +153,7 @@ ); // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'beta')->first()) { + if (!Sku::where('title', 'beta')->first()) { Sku::create( [ 'title' => 'beta', @@ -169,7 +169,7 @@ } // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'meet')->first()) { + if (!Sku::where('title', 'meet')->first()) { Sku::create( [ 'title' => 'meet', @@ -185,7 +185,7 @@ } // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'group')->first()) { + if (!Sku::where('title', 'group')->first()) { Sku::create( [ 'title' => 'group', @@ -201,8 +201,8 @@ } // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'distlist')->first()) { - \App\Sku::create([ + if (!Sku::where('title', 'distlist')->first()) { + Sku::create([ 'title' => 'distlist', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', @@ -213,5 +213,19 @@ 'active' => true, ]); } + + // Check existence because migration might have added this already + if (!Sku::where('title', 'beta-resources')->first()) { + Sku::create([ + 'title' => 'beta-resources', + 'name' => 'Calendaring resources', + 'description' => 'Access to calendaring resources', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Resources', + 'active' => true, + ]); + } } } diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php --- a/src/include/rcube_imap_generic.php +++ b/src/include/rcube_imap_generic.php @@ -3787,6 +3787,11 @@ // remove spaces from the beginning of the string $str = ltrim($str); + // empty string + if ($str === '' || $str === null) { + break; + } + switch ($str[0]) { // String literal @@ -3834,11 +3839,6 @@ // String atom, number, astring, NIL, *, % default: - // empty string - if ($str === '' || $str === null) { - break 2; - } - // excluded chars: SP, CTL, ), DEL // we do not exclude [ and ] (#1489223) if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) { diff --git a/src/resources/js/admin/routes.js b/src/resources/js/admin/routes.js --- a/src/resources/js/admin/routes.js +++ b/src/resources/js/admin/routes.js @@ -4,6 +4,7 @@ import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' +import ResourceComponent from '../../vue/Admin/Resource' import StatsComponent from '../../vue/Admin/Stats' import UserComponent from '../../vue/Admin/User' @@ -41,6 +42,12 @@ component: LogoutComponent }, { + path: '/resource/:resource', + name: 'resource', + component: ResourceComponent, + meta: { requiresAuth: true } + }, + { path: '/stats', name: 'stats', component: StatsComponent, 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 @@ -189,6 +189,18 @@ $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, + // Create an object copy with specified properties only + pick(obj, properties) { + let result = {} + + properties.forEach(prop => { + if (prop in obj) { + result[prop] = obj[prop] + } + }) + + return result + }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() @@ -355,6 +367,12 @@ return page ? page : '404' }, + resourceStatusClass(resource) { + return this.userStatusClass(resource) + }, + resourceStatusText(resource) { + return this.userStatusText(resource) + }, supportDialog(container) { let dialog = $('#support-dialog')[0] @@ -515,8 +533,12 @@ input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) - } - else { + } else { + // a special case, e.g. the invitation policy widget + if (input.is('select') && input.parent().is('.input-group-select.selected')) { + input = input.next() + } + // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -11,6 +11,7 @@ import { faCheck, faCheckCircle, + faCog, faComments, faDownload, faEnvelope, @@ -41,6 +42,7 @@ faCheck, faCheckCircle, faCheckSquare, + faCog, faComments, faCreditCard, faPaypal, diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js --- a/src/resources/js/reseller/routes.js +++ b/src/resources/js/reseller/routes.js @@ -5,6 +5,7 @@ import LoginComponent from '../../vue/Login' import LogoutComponent from '../../vue/Logout' import PageComponent from '../../vue/Page' +import ResourceComponent from '../../vue/Admin/Resource' import StatsComponent from '../../vue/Reseller/Stats' import UserComponent from '../../vue/Admin/User' import WalletComponent from '../../vue/Wallet' @@ -49,6 +50,12 @@ meta: { requiresAuth: true } }, { + path: '/resource/:resource', + name: 'resource', + component: ResourceComponent, + meta: { requiresAuth: true } + }, + { path: '/stats', name: 'stats', component: StatsComponent, diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -8,6 +8,8 @@ import MeetComponent from '../../vue/Rooms' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' +import ResourceInfoComponent from '../../vue/Resource/Info' +import ResourceListComponent from '../../vue/Resource/List' import SignupComponent from '../../vue/Signup' import UserInfoComponent from '../../vue/User/Info' import UserListComponent from '../../vue/User/List' @@ -79,6 +81,18 @@ meta: { requiresAuth: true } }, { + path: '/resource/:resource', + name: 'resource', + component: ResourceInfoComponent, + meta: { requiresAuth: true, perm: 'resources' } + }, + { + path: '/resources', + name: 'resources', + component: ResourceListComponent, + meta: { requiresAuth: true, perm: 'resources' } + }, + { component: RoomComponent, name: 'room', path: '/meet/:room', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -28,21 +28,24 @@ 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', - 'process-distlist-new' => 'Registering a distribution list...', - 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', - 'process-error-user-ldap-ready' => 'Failed to create a user.', - 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', + 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', + 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', + 'process-error-resource-ldap-ready' => 'Failed to create a resource.', + 'process-error-user-ldap-ready' => 'Failed to create a user.', + 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', - 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', + 'process-resource-new' => 'Registering a resource...', + 'process-resource-imap-ready' => 'Creating a shared folder...', + 'process-resource-ldap-ready' => 'Creating a resource...', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', @@ -60,6 +63,11 @@ 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', + 'resource-update-success' => 'Resource updated successfully.', + 'resource-create-success' => 'Resource created successfully.', + 'resource-delete-success' => 'Resource deleted successfully.', + 'resource-setconfig-success' => 'Resource settings updated successfully.', + 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', @@ -71,7 +79,8 @@ 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', - 'search-foundxgroups' => ':x distribution lists have been found.', + 'search-foundxdistlists' => ':x distribution lists have been found.', + 'search-foundxresources' => ':x resources have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 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 @@ -44,6 +44,7 @@ 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", + 'resources' => "Resources", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", @@ -119,12 +120,14 @@ 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", + 'name' => "Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", + 'shared-folder' => "Shared Folder", 'status' => "Status", 'surname' => "Surname", 'user' => "User", @@ -289,6 +292,21 @@ . " Enter the code we sent you, or click the link in the message.", ], + 'resource' => [ + 'create' => "Create resource", + 'delete' => "Delete resource", + 'invitation-policy' => "Invitation policy", + 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" + . " if there is no conflicting event on the requested time slot. Invitation policy allows" + . " for rejecting such requests or to require a manual acceptance from a specified user.", + 'ipolicy-manual' => "Manual (tentative)", + 'ipolicy-accept' => "Accept", + 'ipolicy-reject' => "Reject", + 'list-title' => "Resource | Resources", + 'list-empty' => "There are no resources in this account.", + 'new' => "New resource", + ], + 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", @@ -303,12 +321,14 @@ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", + 'prepare-resource' => "We are preparing the resource.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", + 'ready-resource' => "The resource is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", @@ -359,7 +379,6 @@ 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", - 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", @@ -386,6 +405,7 @@ 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", + 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -140,10 +140,10 @@ 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', + 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', - 'nametoolong' => 'The specified name is too long.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php --- a/src/resources/lang/fr/app.php +++ b/src/resources/lang/fr/app.php @@ -71,7 +71,7 @@ 'user-set-sku-already-exists' => "La souscription existe déjà.", 'search-foundxdomains' => "Les domaines :x ont été trouvés.", - 'search-foundxgroups' => "Les listes de distribution :x ont été trouvées.", + 'search-foundxdistlists' => "Les listes de distribution :x ont été trouvées.", 'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.", 'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.", diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -358,7 +358,6 @@ 'discount-hint' => "rabais appliqué", 'discount-title' => "Rabais de compte", 'distlists' => "Listes de Distribution", - 'distlists-none' => "Il y a aucune liste de distribution dans ce compte.", 'domains' => "Domaines", 'domains-none' => "Il y a pas de domaines dans ce compte.", 'ext-email' => "E-mail externe", diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -292,6 +292,9 @@ } // Some icons are too big, scale them down + &.link-domains, + &.link-resources, + &.link-wallet, &.link-invitations { svg { transform: scale(0.9); diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -69,6 +69,26 @@ } } +// An input group with a select and input, where input is displayed +// only for some select values +.input-group-select { + &:not(.selected) { + input { + display: none; + } + + select { + border-bottom-right-radius: .25rem !important; + border-top-right-radius: .25rem !important; + } + } + + input { + border-bottom-right-radius: .25rem !important; + border-top-right-radius: .25rem !important; + } +} + .form-control-plaintext .btn-sm { margin-top: -0.25rem; } diff --git a/src/resources/vue/Admin/Resource.vue b/src/resources/vue/Admin/Resource.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Admin/Resource.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -118,6 +118,11 @@ +