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
@@ -33,7 +33,7 @@
/**
* Check if a shared folder is set up.
*
- * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld
+ * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld
*
* @return bool True if a folder exists and is set up, False otherwise
*/
@@ -43,7 +43,7 @@
$imap = self::initIMAP($config);
// Convert the folder from UTF8 to UTF7-IMAP
- if (\preg_match('|^(shared/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) {
+ if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) {
$folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8');
$folder = $matches[1] . $folderName . $matches[3];
}
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
@@ -5,6 +5,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\User;
class LDAP
@@ -20,6 +21,12 @@
'invitation_policy',
];
+ /** @const array Shared folder settings used by the backend */
+ public const SHARED_FOLDER_SETTINGS = [
+ 'folder',
+ 'acl',
+ ];
+
/** @const array User settings used by the backend */
public const USER_SETTINGS = [
'first_name',
@@ -296,6 +303,45 @@
}
}
+ /**
+ * Create a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to create.
+ *
+ * @throws \Exception
+ */
+ public static function createSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $domainName = explode('@', $folder->email, 2)[1];
+ $cn = $ldap->quote_string($folder->name);
+ $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders');
+
+ $entry = [
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ ];
+
+ self::setSharedFolderAttributes($ldap, $folder, $entry);
+
+ self::addEntry(
+ $ldap,
+ $dn,
+ $entry,
+ "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Create a user in LDAP.
*
@@ -456,6 +502,34 @@
}
}
+ /**
+ * Delete a shared folder from LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to delete.
+ *
+ * @throws \Exception
+ */
+ public static function deleteSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) {
+ $result = $ldap->delete_entry($dn);
+
+ if (!$result) {
+ self::throwException(
+ $ldap,
+ "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")"
+ );
+ }
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Delete a user from LDAP.
*
@@ -554,6 +628,28 @@
return $resource;
}
+ /**
+ * Get a shared folder data from LDAP.
+ *
+ * @param string $email The resource email.
+ *
+ * @return array|false|null
+ * @throws \Exception
+ */
+ public static function getSharedFolder(string $email)
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $folder = self::getSharedFolderEntry($ldap, $email, $dn);
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+
+ return $folder;
+ }
+
/**
* Get a user data from LDAP.
*
@@ -694,6 +790,43 @@
}
}
+ /**
+ * Update a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to update
+ *
+ * @throws \Exception
+ */
+ public static function updateSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn);
+
+ if (empty($oldEntry)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (folder not found)"
+ );
+ }
+
+ self::setSharedFolderAttributes($ldap, $folder, $newEntry);
+
+ $result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
+
+ if (!is_array($result)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Update a user in LDAP.
*
@@ -886,6 +1019,19 @@
}
}
+ /**
+ * Set common shared folder attributes
+ */
+ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry)
+ {
+ $settings = $folder->getSettings(['acl', 'folder']);
+
+ $entry['cn'] = $folder->name;
+ $entry['kolabfoldertype'] = $folder->type;
+ $entry['kolabtargetfolder'] = $settings['folder'] ?? '';
+ $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : '';
+ }
+
/**
* Set common user attributes
*/
@@ -1027,6 +1173,28 @@
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
+ /**
+ * Get a shared folder entry from LDAP.
+ *
+ * @param \Net_LDAP3 $ldap Ldap connection
+ * @param string $email Resource email (mail)
+ * @param string $dn Reference to the shared folder DN
+ *
+ * @return null|array Shared folder entry, NULL if not found
+ */
+ private static function getSharedFolderEntry($ldap, $email, &$dn = null)
+ {
+ $domainName = explode('@', $email, 2)[1];
+ $base_dn = self::baseDN($domainName, 'Shared Folders');
+
+ $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl'];
+
+ // 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
+ // the new name, but not the old one. Email address is constant.
+ return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
+ }
+
/**
* Get user entry from LDAP.
*
diff --git a/src/app/Console/Commands/SharedFoldersCommand.php b/src/app/Console/Commands/SharedFoldersCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/SharedFoldersCommand.php
@@ -0,0 +1,12 @@
+exists()
|| \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
+ || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Beta/SharedFolders.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/SharedFolder.php b/src/app/Handlers/SharedFolder.php
--- a/src/app/Handlers/SharedFolder.php
+++ b/src/app/Handlers/SharedFolder.php
@@ -11,7 +11,6 @@
*/
public static function entitleableClass(): string
{
- // TODO
- return '';
+ return \App\SharedFolder::class;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
@@ -0,0 +1,59 @@
+input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::find($owner)) {
+ $result = $owner->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Create a new shared folder.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ return $this->errorResponse(404);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/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
@@ -63,6 +63,8 @@
$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();
}
if (!$user_ids->isEmpty()) {
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -167,7 +167,7 @@
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
- && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT
+ && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
@@ -0,0 +1,46 @@
+input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::withSubjectTenantContext()->find($owner)) {
+ $result = $owner->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
@@ -53,6 +53,10 @@
// Search by a distribution list email
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();
}
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
@@ -209,7 +209,6 @@
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
- $errors = $v->errors()->toArray();
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
@@ -0,0 +1,357 @@
+errorResponse(404);
+ }
+
+ /**
+ * Delete a shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canDelete($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $folder->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-delete-success'),
+ ]);
+ }
+
+ /**
+ * Show the form for editing the specified shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Listing of a shared folders belonging to the authenticated user.
+ *
+ * The shared-folder entitlements billed to the current user wallet(s)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = $this->guard()->user();
+
+ $result = $user->sharedFolders()->orderBy('name')->get()
+ ->map(function (SharedFolder $folder) {
+ return $this->objectToClient($folder);
+ });
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set the shared folder configuration.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $folder->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-setconfig-success'),
+ ]);
+ }
+
+ /**
+ * Display information of a shared folder specified by $id.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->objectToClient($folder, true);
+
+ $response['statusInfo'] = self::statusInfo($folder);
+
+ // Shared folder configuration, e.g. acl
+ $response['config'] = $folder->getConfig();
+
+ return response()->json($response);
+ }
+
+ /**
+ * Fetch a shared folder status (and reload setup process)
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function status($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->processStateUpdate($folder);
+ $response = array_merge($response, self::objectState($folder));
+
+ return response()->json($response);
+ }
+
+ /**
+ * SharedFolder status (extended) information
+ *
+ * @param \App\SharedFolder $folder SharedFolder object
+ *
+ * @return array Status information
+ */
+ public static function statusInfo(SharedFolder $folder): array
+ {
+ return self::processStateInfo(
+ $folder,
+ [
+ 'shared-folder-new' => true,
+ 'shared-folder-ldap-ready' => $folder->isLdapReady(),
+ 'shared-folder-imap-ready' => $folder->isImapReady(),
+ ]
+ );
+ }
+
+ /**
+ * Create a new shared folder record.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ $current_user = $this->guard()->user();
+ $owner = $current_user->wallet()->owner;
+
+ if ($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);
+ }
+
+ DB::beginTransaction();
+
+ // Create the shared folder
+ $folder = new SharedFolder();
+ $folder->name = request()->input('name');
+ $folder->type = request()->input('type');
+ $folder->domain = $domain;
+ $folder->save();
+
+ $folder->assignToWallet($owner->wallets->first());
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-create-success'),
+ ]);
+ }
+
+ /**
+ * Update a shared folder.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+
+ if (!$current_user->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $owner = $folder->wallet()->owner;
+
+ $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);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ } else {
+ $folder->name = $name;
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $folder->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-update-success'),
+ ]);
+ }
+
+ /**
+ * Execute (synchronously) specified step in a shared folder setup process.
+ *
+ * @param \App\SharedFolder $folder Shared folder object
+ * @param string $step Step identifier (as in self::statusInfo())
+ *
+ * @return bool|null True if the execution succeeded, False if not, Null when
+ * the job has been sent to the worker (result unknown)
+ */
+ public static function execProcessStep(SharedFolder $folder, string $step): ?bool
+ {
+ try {
+ if (strpos($step, 'domain-') === 0) {
+ return DomainsController::execProcessStep($folder->domain(), $step);
+ }
+
+ switch ($step) {
+ case 'shared-folder-ldap-ready':
+ // Shared folder not in LDAP, create it
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isLdapReady();
+
+ case 'shared-folder-imap-ready':
+ // Shared folder 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\SharedFolder\VerifyJob::dispatch($folder->id);
+
+ return null;
+ }
+
+ $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isImapReady();
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepare shared folder statuses for the UI
+ *
+ * @param \App\SharedFolder $folder Shared folder object
+ *
+ * @return array Statuses array
+ */
+ protected static function objectState(SharedFolder $folder): array
+ {
+ return [
+ 'isLdapReady' => $folder->isLdapReady(),
+ 'isImapReady' => $folder->isImapReady(),
+ 'isActive' => $folder->isActive(),
+ 'isDeleted' => $folder->isDeleted() || $folder->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
@@ -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 'enableFolders' working for wallet controllers that aren't account owners
+ 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus),
'enableUsers' => $isController,
diff --git a/src/app/Jobs/CommonJob.php b/src/app/Jobs/CommonJob.php
--- a/src/app/Jobs/CommonJob.php
+++ b/src/app/Jobs/CommonJob.php
@@ -29,6 +29,13 @@
*/
public $failureMessage;
+ /**
+ * The job deleted state.
+ *
+ * @var bool
+ */
+ protected $isDeleted = false;
+
/**
* The job released state.
*
@@ -50,6 +57,22 @@
*/
abstract public function handle();
+ /**
+ * Delete the job from the queue.
+ *
+ * @return void
+ */
+ public function delete()
+ {
+ // We need this for testing purposes
+ $this->isDeleted = true;
+
+ // @phpstan-ignore-next-line
+ if ($this->job) {
+ $this->job->delete();
+ }
+ }
+
/**
* Delete the job, call the "failed" method, and raise the failed job event.
*
@@ -95,6 +118,16 @@
}
}
+ /**
+ * Determine if the job has been deleted.
+ *
+ * @return bool
+ */
+ public function isDeleted(): bool
+ {
+ return $this->isDeleted;
+ }
+
/**
* Check if the job was released
*
diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php
--- a/src/app/Jobs/Resource/VerifyJob.php
+++ b/src/app/Jobs/Resource/VerifyJob.php
@@ -19,7 +19,7 @@
return;
}
- // the user has a mailbox (or is marked as such)
+ // the resource was already verified
if ($resource->isImapReady()) {
$this->fail(new \Exception("Resource {$this->resourceId} is already verified."));
return;
diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/CreateJob.php
@@ -0,0 +1,61 @@
+getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if ($folder->trashed()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted."));
+ return;
+ }
+
+ if ($folder->isLdapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // see if the domain is ready
+ $domain = $folder->domain();
+
+ if (!$domain) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist."));
+ return;
+ }
+
+ if ($domain->isDeleted()) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if (!$domain->isLdapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ \App\Backends\LDAP::createSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_LDAP_READY;
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/DeleteJob.php
@@ -0,0 +1,42 @@
+getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted."));
+ return;
+ }
+
+ \App\Backends\LDAP::deleteSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_DELETED;
+
+ if ($folder->isLdapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY;
+ }
+
+ if ($folder->isImapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY;
+ }
+
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/UpdateJob.php
@@ -0,0 +1,30 @@
+getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // Cancel the update if the folder is deleted or not yet in LDAP
+ if (!$folder->isLdapReady() || $folder->isDeleted()) {
+ $this->delete();
+ return;
+ }
+
+ \App\Backends\LDAP::updateSharedFolder($folder);
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/VerifyJob.php b/src/app/Jobs/SharedFolder/VerifyJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/VerifyJob.php
@@ -0,0 +1,36 @@
+getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // the user has a mailbox (or is marked as such)
+ if ($folder->isImapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already verified."));
+ return;
+ }
+
+ $folderName = $folder->getSetting('folder');
+
+ if (\App\Backends\IMAP::verifySharedFolder($folderName)) {
+ $folder->status |= \App\SharedFolder::STATUS_IMAP_READY;
+ $folder->status |= \App\SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ }
+ }
+}
diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolderJob.php
@@ -0,0 +1,72 @@
+handle();
+ * ```
+ */
+abstract class SharedFolderJob extends CommonJob
+{
+ /**
+ * The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space
+ * compared to a serialized version of the complete \App\SharedFolder object.
+ *
+ * @var int
+ */
+ protected $folderId;
+ /**
+ * The \App\SharedFolder email property, for legibility in the queue management.
+ *
+ * @var string
+ */
+ protected $folderEmail;
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $folderId The ID for the shared folder to process.
+ *
+ * @return void
+ */
+ public function __construct(int $folderId)
+ {
+ $this->folderId = $folderId;
+
+ $folder = $this->getSharedFolder();
+
+ if ($folder) {
+ $this->folderEmail = $folder->email;
+ }
+ }
+
+ /**
+ * Get the \App\SharedFolder entry associated with this job.
+ *
+ * @return \App\SharedFolder|null
+ *
+ * @throws \Exception
+ */
+ protected function getSharedFolder()
+ {
+ $folder = \App\SharedFolder::withTrashed()->find($this->folderId);
+
+ if (!$folder) {
+ // 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 SharedFolder\CreateJob) {
+ $this->release(5);
+ return null;
+ }
+
+ $this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database."));
+ }
+
+ return $folder;
+ }
+}
diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SharedFolderObserver.php
@@ -0,0 +1,140 @@
+type)) {
+ $folder->type = 'mail';
+ }
+
+ if (empty($folder->email)) {
+ if (!isset($folder->name)) {
+ 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;
+ }
+
+ /**
+ * Handle the shared folder "created" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function created(SharedFolder $folder)
+ {
+ $domainName = explode('@', $folder->email, 2)[1];
+
+ $settings = [
+ 'folder' => "shared/{$folder->name}@{$domainName}",
+ ];
+
+ foreach ($settings as $key => $value) {
+ $settings[$key] = [
+ 'key' => $key,
+ 'value' => $value,
+ 'shared_folder_id' => $folder->id,
+ ];
+ }
+
+ // Note: Don't use setSettings() here to bypass SharedFolderSetting observers
+ // Note: This is a single multi-insert query
+ $folder->settings()->insert(array_values($settings));
+
+ // Create folder record in LDAP, then check if it is created in IMAP
+ $chain = [
+ new \App\Jobs\SharedFolder\VerifyJob($folder->id),
+ ];
+
+ \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "deleting" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleting(SharedFolder $folder)
+ {
+ // 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', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->delete();
+ }
+
+ /**
+ * Handle the shared folder "deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleted(SharedFolder $folder)
+ {
+ if ($folder->isForceDeleting()) {
+ return;
+ }
+
+ \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "updated" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function updated(SharedFolder $folder)
+ {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id);
+
+ // Update the folder property if name changed
+ if ($folder->name != $folder->getOriginal('name')) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $folderName = "shared/{$folder->name}@{$domainName}";
+
+ // Note: This does not invoke SharedFolderSetting observer events, good.
+ $folder->settings()->where('key', 'folder')->update(['value' => $folderName]);
+ }
+ }
+
+ /**
+ * Handle the shared folder "force deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function forceDeleted(SharedFolder $folder)
+ {
+ // A folder can be force-deleted separately from the owner
+ // we have to force-delete entitlements
+ \App\Entitlement::where('entitleable_id', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->forceDelete();
+ }
+}
diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SharedFolderSettingObserver.php
@@ -0,0 +1,51 @@
+key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "updated" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function updated(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "deleted" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function deleted(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_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
@@ -6,6 +6,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\Transaction;
use App\User;
use App\Wallet;
@@ -149,6 +150,7 @@
$domains = [];
$groups = [];
$resources = [];
+ $folders = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
@@ -160,6 +162,8 @@
$groups[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Resource::class) {
$resources[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
@@ -191,6 +195,12 @@
}
}
+ if (!empty($folders)) {
+ foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) {
+ $_folder->delete();
+ }
+ }
+
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
@@ -222,6 +232,7 @@
$domains = [];
$groups = [];
$resources = [];
+ $folders = [];
$users = [];
foreach ($assignments as $entitlement) {
@@ -238,6 +249,8 @@
$groups[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Resource::class) {
$resources[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $entitlement->entitleable_id;
}
}
@@ -270,6 +283,11 @@
Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete();
}
+ // Shared folders can be just removed
+ if (!empty($folders)) {
+ SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->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
@@ -53,6 +53,8 @@
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\Resource::observe(\App\Observers\ResourceObserver::class);
\App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
+ \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
+ \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::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
--- a/src/app/Resource.php
+++ b/src/app/Resource.php
@@ -48,8 +48,7 @@
'status',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a resource to be created */
public $domain;
diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/ResourceName.php
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/ResourceName.php
@@ -12,6 +12,8 @@
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* Class constructor.
*
@@ -39,13 +41,18 @@
return false;
}
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
// Check the max length, according to the database column length
if (strlen($name) > 191) {
$this->message = \trans('validation.max.string', ['max' => 191]);
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain 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');
diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/SharedFolderName.php
copy from src/app/Rules/ResourceName.php
copy to src/app/Rules/SharedFolderName.php
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/SharedFolderName.php
@@ -6,12 +6,14 @@
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
-class ResourceName implements Rule
+class SharedFolderName implements Rule
{
private $message;
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* Class constructor.
*
@@ -28,13 +30,18 @@
* Determine if the validation rule passes.
*
* @param string $attribute Attribute name
- * @param mixed $name Resource name input
+ * @param mixed $name Shared folder name input
*
* @return bool
*/
public function passes($attribute, $name): bool
{
- if (empty($name) || !is_string($name)) {
+ if (empty($name) || !is_string($name) || $name == 'Resources') {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) {
$this->message = \trans('validation.nameinvalid');
return false;
}
@@ -45,7 +52,7 @@
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain 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');
@@ -53,10 +60,10 @@
}
// Check if the name is unique in the 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)
+ // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()?
+ $exists = $this->owner->sharedFolders()
+ ->where('shared_folders.name', $name)
+ ->where('shared_folders.email', 'like', '%@' . $this->domain)
->exists();
if ($exists) {
diff --git a/src/app/Rules/SharedFolderType.php b/src/app/Rules/SharedFolderType.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/SharedFolderType.php
@@ -0,0 +1,40 @@
+message = \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Resource.php b/src/app/SharedFolder.php
copy from src/app/Resource.php
copy to src/app/SharedFolder.php
--- a/src/app/Resource.php
+++ b/src/app/SharedFolder.php
@@ -4,7 +4,7 @@
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
-use App\Traits\ResourceConfigTrait;
+use App\Traits\SharedFolderConfigTrait;
use App\Traits\SettingsTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
@@ -12,66 +12,71 @@
use Illuminate\Database\Eloquent\SoftDeletes;
/**
- * The eloquent definition of a Resource.
+ * The eloquent definition of a SharedFolder.
*
- * @property int $id The resource identifier
* @property string $email An email address
- * @property string $name The resource name
- * @property int $status The resource status
+ * @property int $id The folder identifier
+ * @property string $name The folder name
+ * @property int $status The folder status
* @property int $tenant_id Tenant identifier
+ * @property string $type The folder type
*/
-class Resource extends Model
+class SharedFolder extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
- use ResourceConfigTrait;
+ use SharedFolderConfigTrait;
use SettingsTrait;
use SoftDeletes;
use UuidIntKeyTrait;
- // we've simply never heard of this resource
+ // we've simply never heard of this folder
public const STATUS_NEW = 1 << 0;
- // resource has been activated
+ // folder has been activated
public const STATUS_ACTIVE = 1 << 1;
- // resource has been suspended.
+ // folder has been suspended.
// public const STATUS_SUSPENDED = 1 << 2;
- // resource has been deleted
+ // folder has been deleted
public const STATUS_DELETED = 1 << 3;
- // resource has been created in LDAP
+ // folder has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
- // resource has been created in IMAP
+ // folder has been created in IMAP
public const STATUS_IMAP_READY = 1 << 8;
+ /** @const array Supported folder type labels */
+ public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note'];
+
+ /** @var array Mass-assignable properties */
protected $fillable = [
'email',
'name',
'status',
+ 'type',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a shared folder to be created */
public $domain;
/**
- * Assign the resource to a wallet.
+ * Assign the folder to a wallet.
*
* @param \App\Wallet $wallet The wallet
*
- * @return \App\Resource Self
+ * @return \App\SharedFolder Self
* @throws \Exception
*/
- public function assignToWallet(Wallet $wallet): Resource
+ public function assignToWallet(Wallet $wallet): SharedFolder
{
if (empty($this->id)) {
- throw new \Exception("Resource not yet exists");
+ throw new \Exception("Shared folder not yet exists");
}
if ($this->entitlements()->count()) {
- throw new \Exception("Resource already assigned to a wallet");
+ throw new \Exception("Shared folder already assigned to a wallet");
}
- $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first();
+ $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared-folder')->first();
$exists = $wallet->entitlements()->where('sku_id', $sku->id)->count();
\App\Entitlement::create([
@@ -80,16 +85,16 @@
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
- 'entitleable_type' => Resource::class
+ 'entitleable_type' => SharedFolder::class
]);
return $this;
}
/**
- * Returns the resource domain.
+ * Returns the shared folder domain.
*
- * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist
+ * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist
*/
public function domain(): ?Domain
{
@@ -103,14 +108,14 @@
}
/**
- * Find whether an email address exists as a resource (including deleted resources).
+ * Find whether an email address exists as a shared folder (including deleted folders).
*
- * @param string $email Email address
- * @param bool $return_resource Return Resource instance instead of boolean
+ * @param string $email Email address
+ * @param bool $return_folder Return SharedFolder instance instead of boolean
*
- * @return \App\Resource|bool True or Resource model object if found, False otherwise
+ * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise
*/
- public static function emailExists(string $email, bool $return_resource = false)
+ public static function emailExists(string $email, bool $return_folder = false)
{
if (strpos($email, '@') === false) {
return false;
@@ -118,17 +123,17 @@
$email = \strtolower($email);
- $resource = self::withTrashed()->where('email', $email)->first();
+ $folder = self::withTrashed()->where('email', $email)->first();
- if ($resource) {
- return $return_resource ? $resource : true;
+ if ($folder) {
+ return $return_folder ? $folder : true;
}
return false;
}
/**
- * Returns whether this resource is active.
+ * Returns whether this folder is active.
*
* @return bool
*/
@@ -138,7 +143,7 @@
}
/**
- * Returns whether this resource is deleted.
+ * Returns whether this folder is deleted.
*
* @return bool
*/
@@ -148,7 +153,7 @@
}
/**
- * Returns whether this resource's folder exists in IMAP.
+ * Returns whether this folder exists in IMAP.
*
* @return bool
*/
@@ -158,7 +163,7 @@
}
/**
- * Returns whether this resource is registered in LDAP.
+ * Returns whether this folder is registered in LDAP.
*
* @return bool
*/
@@ -168,7 +173,7 @@
}
/**
- * Returns whether this resource is new.
+ * Returns whether this folder is new.
*
* @return bool
*/
@@ -178,7 +183,7 @@
}
/**
- * Resource status mutator
+ * Folder status mutator
*
* @throws \Exception
*/
@@ -202,9 +207,23 @@
}
if ($status > 0) {
- throw new \Exception("Invalid resource status: {$status}");
+ throw new \Exception("Invalid shared folder status: {$status}");
}
$this->attributes['status'] = $new_status;
}
+
+ /**
+ * Folder type mutator
+ *
+ * @throws \Exception
+ */
+ public function setTypeAttribute($type)
+ {
+ if (!in_array($type, self::SUPPORTED_TYPES)) {
+ throw new \Exception("Invalid shared folder type: {$type}");
+ }
+
+ $this->attributes['type'] = $type;
+ }
}
diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/SharedFolderSetting.php
@@ -0,0 +1,30 @@
+belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id');
+ }
+}
diff --git a/src/app/Traits/SharedFolderConfigTrait.php b/src/app/Traits/SharedFolderConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/SharedFolderConfigTrait.php
@@ -0,0 +1,118 @@
+getSettings(['acl']);
+
+ $config['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update a shared folder 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 acl
+ if ($key === 'acl') {
+ // Here's the list of acl labels supported by kolabd
+ // 'all': 'lrsedntxakcpiw',
+ // 'append': 'wip',
+ // 'full': 'lrswipkxtecdn',
+ // 'read': 'lrs',
+ // 'read-only': 'lrs',
+ // 'read-write': 'lrswitedn',
+ // 'post': 'p',
+ // 'semi-full': 'lrswit',
+ // 'write': 'lrswite',
+ // For now we support read-only, read-write, and full
+
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ $users = [];
+
+ foreach ($value as $i => $v) {
+ if (!is_string($v) || empty($v) || !substr_count($v, ',')) {
+ $errors[$key][$i] = \trans('validation.acl-entry-invalid');
+ } else {
+ list($user, $acl) = explode(',', $v, 2);
+ $user = trim($user);
+ $acl = trim($acl);
+ $error = null;
+
+ if (
+ !in_array($acl, ['read-only', 'read-write', 'full'])
+ || ($error = $this->validateAclIdentifier($user))
+ || in_array($user, $users)
+ ) {
+ $errors[$key][$i] = $error ?: \trans('validation.acl-entry-invalid');
+ }
+
+ $value[$i] = "$user, $acl";
+ $users[] = $user;
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Validate an ACL identifier.
+ *
+ * @param string $identifier Email address or a special identifier
+ *
+ * @return ?string Error message on validation error
+ */
+ protected function validateAclIdentifier(string $identifier): ?string
+ {
+ if ($identifier === 'anyone') {
+ return null;
+ }
+
+ $v = Validator::make(['email' => $identifier], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ return \trans('validation.emailinvalid');
+ }
+
+ $user = \App\User::where('email', \strtolower($identifier))->first();
+
+ // The user and shared folder 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
@@ -591,6 +591,29 @@
->where('entitlements.entitleable_type', \App\Resource::class);
}
+ /**
+ * Return shared folders controlled by the current user.
+ *
+ * @param bool $with_accounts Include folders assigned to wallets
+ * the current user controls but not owns.
+ *
+ * @return \Illuminate\Database\Eloquent\Builder Query builder
+ */
+ public function sharedFolders($with_accounts = true)
+ {
+ $wallets = $this->wallets()->pluck('id')->all();
+
+ if ($with_accounts) {
+ $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
+ }
+
+ return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id'])
+ ->distinct()
+ ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id')
+ ->whereIn('entitlements.wallet_id', $wallets)
+ ->where('entitlements.entitleable_type', \App\SharedFolder::class);
+ }
+
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
diff --git a/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
@@ -0,0 +1,83 @@
+unsignedBigInteger('id');
+ $table->string('email')->unique();
+ $table->string('name');
+ $table->string('type', 8);
+ $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(
+ 'shared_folder_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('shared_folder_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamps();
+
+ $table->foreign('shared_folder_id')->references('id')->on('shared_folders')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['shared_folder_id', 'key']);
+ }
+ );
+
+ \App\Sku::where('title', 'shared_folder')->update([
+ 'active' => true,
+ 'cost' => 0,
+ 'title' => 'shared-folder',
+ ]);
+
+ if (!\App\Sku::where('title', 'beta-shared-folders')->first()) {
+ \App\Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('shared_folder_settings');
+ Schema::dropIfExists('shared_folders');
+
+ // 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
@@ -26,6 +26,7 @@
'OpenViduRoomSeeder',
'OauthClientSeeder',
'ResourceSeeder',
+ 'SharedFolderSeeder',
];
$env = ucfirst(App::environment());
diff --git a/src/database/seeds/local/SharedFolderSeeder.php b/src/database/seeds/local/SharedFolderSeeder.php
new file mode 100644
--- /dev/null
+++ b/src/database/seeds/local/SharedFolderSeeder.php
@@ -0,0 +1,35 @@
+first();
+ $wallet = $john->wallets()->first();
+
+ $folder = SharedFolder::create([
+ 'name' => 'Calendar',
+ 'email' => 'folder-event@kolab.org',
+ 'type' => 'event',
+ ]);
+ $folder->assignToWallet($wallet);
+
+ $folder = SharedFolder::create([
+ 'name' => 'Contacts',
+ 'email' => 'folder-contact@kolab.org',
+ 'type' => 'contact',
+ ]);
+ $folder->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
@@ -116,13 +116,13 @@
Sku::create(
[
- 'title' => 'shared_folder',
+ 'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
- 'active' => false,
+ 'active' => true,
]
);
@@ -240,6 +240,22 @@
]);
}
+ // Check existence because migration might have added this already
+ $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ '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
@@ -116,7 +116,7 @@
Sku::create(
[
- 'title' => 'shared_folder',
+ 'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
@@ -227,5 +227,19 @@
'active' => true,
]);
}
+
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', 'beta-shared-folders')->first()) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -12,4 +12,5 @@
processTimeout: 300.0
paths:
- app/
+ - config/
- tests/
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
@@ -5,6 +5,7 @@
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import ResourceComponent from '../../vue/Admin/Resource'
+import SharedFolderComponent from '../../vue/Admin/SharedFolder'
import StatsComponent from '../../vue/Admin/Stats'
import UserComponent from '../../vue/Admin/User'
@@ -47,6 +48,12 @@
component: ResourceComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/stats',
name: 'stats',
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
@@ -351,6 +351,12 @@
return this.$t('status.active')
},
+ folderStatusClass(folder) {
+ return this.userStatusClass(folder)
+ },
+ folderStatusText(folder) {
+ return this.userStatusText(folder)
+ },
pageName(path) {
let page = this.$route.path
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
@@ -15,6 +15,7 @@
faComments,
faDownload,
faEnvelope,
+ faFolderOpen,
faGlobe,
faUniversity,
faExclamationCircle,
@@ -50,6 +51,7 @@
faDownload,
faEnvelope,
faExclamationCircle,
+ faFolderOpen,
faGlobe,
faInfoCircle,
faLock,
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
@@ -6,6 +6,7 @@
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import ResourceComponent from '../../vue/Admin/Resource'
+import SharedFolderComponent from '../../vue/Admin/SharedFolder'
import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
import WalletComponent from '../../vue/Wallet'
@@ -55,6 +56,12 @@
component: ResourceComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/stats',
name: 'stats',
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
@@ -10,6 +10,8 @@
import PasswordResetComponent from '../../vue/PasswordReset'
import ResourceInfoComponent from '../../vue/Resource/Info'
import ResourceListComponent from '../../vue/Resource/List'
+import SharedFolderInfoComponent from '../../vue/SharedFolder/Info'
+import SharedFolderListComponent from '../../vue/SharedFolder/List'
import SignupComponent from '../../vue/Signup'
import UserInfoComponent from '../../vue/User/Info'
import UserListComponent from '../../vue/User/List'
@@ -104,6 +106,18 @@
component: MeetComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderInfoComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
+ {
+ path: '/shared-folders',
+ name: 'shared-folders',
+ component: SharedFolderListComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
{
path: '/signup/invite/:param',
name: 'signup-invite',
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
@@ -39,6 +39,8 @@
'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-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
+ 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'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...',
@@ -46,6 +48,9 @@
'process-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
+ 'process-shared-folder-new' => 'Registering a shared folder...',
+ 'process-shared-folder-imap-ready' => 'Creating a shared folder...',
+ 'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
@@ -68,6 +73,11 @@
'resource-delete-success' => 'Resource deleted successfully.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
+ 'shared-folder-update-success' => 'Shared folder updated successfully.',
+ 'shared-folder-create-success' => 'Shared folder created successfully.',
+ 'shared-folder-delete-success' => 'Shared folder deleted successfully.',
+ 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.',
+
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
@@ -81,6 +91,7 @@
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
+ 'search-foundxsharedfolders' => ':x shared folders 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
@@ -45,6 +45,7 @@
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
+ 'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
@@ -107,7 +108,12 @@
],
'form' => [
+ 'acl' => "Access rights",
+ 'acl-full' => "All",
+ 'acl-read-only' => "Read-only",
+ 'acl-read-write' => "Read-write",
'amount' => "Amount",
+ 'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
'date' => "Date",
@@ -130,6 +136,7 @@
'shared-folder' => "Shared Folder",
'status' => "Status",
'surname' => "Surname",
+ 'type' => "Type",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
@@ -307,6 +314,21 @@
'new' => "New resource",
],
+ 'shf' => [
+ 'create' => "Create folder",
+ 'delete' => "Delete folder",
+ 'acl-text' => "Defines user permissions to access the shared folder.",
+ 'list-title' => "Shared folder | Shared folders",
+ 'list-empty' => "There are no shared folders in this account.",
+ 'new' => "New shared folder",
+ 'type-mail' => "Mail",
+ 'type-event' => "Calendar",
+ 'type-contact' => "Address Book",
+ 'type-task' => "Tasks",
+ 'type-note' => "Notes",
+ 'type-file' => "Files",
+ ],
+
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
@@ -322,6 +344,7 @@
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
+ 'prepare-shared-folder' => "We are preparing the shared folder.",
'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.",
@@ -329,6 +352,7 @@
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
+ 'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify 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,6 +140,7 @@
'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.',
+ 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.',
'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.',
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
@@ -28,6 +28,13 @@
}
}
+.acl-input {
+ select.acl,
+ select.mod-user {
+ max-width: fit-content;
+ }
+}
+
.range-input {
display: flex;
diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Admin/SharedFolder.vue
@@ -0,0 +1,91 @@
+
+