Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117748596
D5841.1775178394.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
116 KB
Referenced Files
None
Subscribers
None
D5841.1775178394.diff
View Options
diff --git a/src/app/DataMigrator/Driver/Kolab.php b/src/app/DataMigrator/Driver/Kolab.php
--- a/src/app/DataMigrator/Driver/Kolab.php
+++ b/src/app/DataMigrator/Driver/Kolab.php
@@ -183,9 +183,8 @@
throw new \Exception("Kolab v4 source not supported");
}
- // IMAP (and DAV)
- // TODO: We can treat 'file' folders the same, but we have no sharing in Kolab4 yet for them
- if (in_array($folder->type, array_merge(self::DAV_TYPES, [Engine::TYPE_MAIL, Engine::TYPE_NOTE]))) {
+ // IMAP, DAV and other types
+ if (in_array($folder->type, array_merge(self::DAV_TYPES, [Engine::TYPE_MAIL, Engine::TYPE_NOTE, Engine::TYPE_FILE]))) {
parent::fetchFolder($folder);
return;
}
diff --git a/src/app/DataMigrator/Driver/Kolab/Fs.php b/src/app/DataMigrator/Driver/Kolab/Fs.php
--- a/src/app/DataMigrator/Driver/Kolab/Fs.php
+++ b/src/app/DataMigrator/Driver/Kolab/Fs.php
@@ -5,6 +5,8 @@
use App\DataMigrator\Account;
use App\DataMigrator\Interface\Folder;
use App\Fs\Item as FsItem;
+use App\Fs\Share as FsShare;
+use App\User;
use Illuminate\Support\Facades\DB;
/**
@@ -21,7 +23,40 @@
public static function createFolder(Account $account, Folder $folder): void
{
// We assume destination is the local server. Maybe we should be using Cockpit API?
- self::getFsCollection($account, $folder, true);
+ $collection = self::getFsCollection($account, $folder, true);
+
+ if (!empty($folder->acl)) {
+ $emails = array_diff(array_keys($folder->acl), [$account->email]);
+ $acl = [];
+
+ // Map IMAP ACL into Kolab Fs shares
+ foreach (User::whereIn('email', $emails)->pluck('email', 'id') as $user_id => $email) {
+ $rights = $folder->acl[$email];
+ if (in_array('w', $rights) || in_array('a', $rights)) {
+ $acl[$user_id] = FsShare::READ | FsShare::WRITE;
+ } elseif (in_array('r', $rights)) {
+ $acl[$user_id] = FsShare::READ;
+ }
+ }
+
+ if (!empty($acl)) {
+ $existing = $collection->shares()->where('sharee_type', User::class)->get()->keyBy('sharee_id')->all();
+
+ // Insert or update shares
+ foreach ($acl as $user_id => $rights) {
+ if (!empty($existing[$user_id])) {
+ $existing[$user_id]->rights = $rights;
+ $existing[$user_id]->save();
+ } else {
+ $collection->shares()->create([
+ 'sharee_id' => $user_id,
+ 'sharee_type' => User::class,
+ 'rights' => $rights,
+ ]);
+ }
+ }
+ }
+ }
}
/**
diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php
--- a/src/app/Fs/Item.php
+++ b/src/app/Fs/Item.php
@@ -5,11 +5,14 @@
use App\Backends\Storage;
use App\Traits\BelongsToUserTrait;
use App\Traits\UuidStrKeyTrait;
+use App\User;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Database\Query\JoinClause;
+use Illuminate\Support\Collection;
/**
* The eloquent definition of a filesystem item.
@@ -102,6 +105,33 @@
return $copy;
}
+ /**
+ * Recursive method to find share records for item and its parent(s)
+ */
+ protected function findShares(User $user, bool $include_self = true): array
+ {
+ if ($include_self && $this->isCollection()) {
+ // TODO: Group support
+ $share = $this->shares()
+ ->where('sharee_id', $user->id)->where('sharee_type', User::class)->first();
+
+ if ($share) {
+ return [$share];
+ }
+ }
+
+ // FIXME: In theory an item can have multiple parents, what if one is
+ // writable and the other not?
+
+ $shares = [];
+ foreach ($this->parents()->get() as $parent) {
+ $parent_shares = $parent->findShares($user);
+ $shares = array_merge($shares, $parent_shares);
+ }
+
+ return $shares;
+ }
+
/**
* Getter for the file path (without the filename) in the storage.
*/
@@ -196,6 +226,69 @@
return (bool) ($this->type & self::TYPE_NOTEBOOK);
}
+ /**
+ * Check if a user can edit/delete the item.
+ *
+ * @param User $user User
+ */
+ public function isEditable(User $user): bool
+ {
+ if ($this->user_id == $user->id) {
+ return true;
+ }
+
+ foreach ($this->findShares($user, include_self: false) as $share) {
+ if ($share->rights & Share::WRITE) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a user can read the item.
+ *
+ * @param User $user User
+ */
+ public function isReadable(User $user): bool
+ {
+ if ($this->user_id == $user->id) {
+ return true;
+ }
+
+ foreach ($this->findShares($user, include_self: true) as $share) {
+ if ($share->rights & Share::READ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a user can edit/delete items in the collection.
+ * Returns false if the item is not a collection.
+ *
+ * @param User $user User
+ */
+ public function isWritable(User $user): bool
+ {
+ if ($this->isCollection()) {
+ if ($this->user_id == $user->id) {
+ return true;
+ }
+
+ foreach ($this->findShares($user, include_self: true) as $share) {
+ if ($share->rights & Share::WRITE) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
/**
* Move the item to another location
*
@@ -315,6 +408,36 @@
return $this->belongsToMany(self::class, 'fs_relations', 'related_id', 'item_id');
}
+ /**
+ * Share records for this item.
+ *
+ * @return HasMany<Share, $this>
+ */
+ public function shares()
+ {
+ return $this->hasMany(Share::class);
+ }
+
+ /**
+ * Share records for this item in an extended format.
+ */
+ public function sharesList(): Collection
+ {
+ // TODO: Groups support
+ return $this->shares()
+ ->select('fs_shares.*', 'users.email')
+ ->leftJoin(
+ 'users',
+ function (JoinClause $join) {
+ $join->on('users.id', '=', 'fs_shares.sharee_id')
+ ->where('fs_shares.sharee_type', User::class);
+ }
+ )
+ ->orderBy('email')
+ ->get()
+ ->keyBy('email');
+ }
+
/**
* Item type mutator
*
diff --git a/src/app/Fs/Share.php b/src/app/Fs/Share.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Share.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Fs;
+
+use App\User;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+/**
+ * The eloquent definition of a filesystem share.
+ *
+ * @property int $id Share record identifier
+ * @property string $item_id Filesystem item identifier
+ * @property int $rights Rights (sum of: 1 - read, 2 - write)
+ * @property int $sharee_id Sharee identifier
+ * @property string $sharee_type Sharee type (e.g. 'App\User')
+ */
+class Share extends Model
+{
+ public const READ = 1;
+ public const WRITE = 2;
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'rights' => 'integer',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = ['item_id', 'rights', 'sharee_id', 'sharee_type'];
+
+ /** @var string Database table name */
+ protected $table = 'fs_shares';
+
+ /**
+ * The filesystem item.
+ *
+ * @return BelongsTo<Item, $this>
+ */
+ public function item()
+ {
+ return $this->belongsTo(Item::class);
+ }
+
+ /**
+ * Ensure the value of sharee rights.
+ *
+ * @param int $rights Rights
+ */
+ public function setRightsAttribute(int $rights)
+ {
+ if ($rights == 0) {
+ throw new \Exception("Sharing rights must not be empty");
+ }
+
+ if ($rights != 1 && $rights != 3) {
+ throw new \Exception("Unsupported sharing rights ({$rights})");
+ }
+
+ $this->attributes['rights'] = $rights;
+ }
+
+ /**
+ * Principally entitleable object such as User or Group.
+ * Note that it may be trashed (soft-deleted).
+ */
+ public function sharee()
+ {
+ return $this->morphTo()->withTrashed();
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Fs/PermissionsTrait.php b/src/app/Http/Controllers/API/V4/Fs/PermissionsTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Fs/PermissionsTrait.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Fs;
+
+use App\Fs\Property;
+use App\Utils;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Support\Facades\Validator;
+
+trait PermissionsTrait
+{
+ /**
+ * List permissions for the specific file.
+ *
+ * @param string $fileId the file identifier
+ *
+ * @return JsonResponse
+ */
+ public function getPermissions($fileId)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputItem($fileId, self::OWNER);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $result = $file->properties()->whereLike('key', 'share-%')->get()->map(
+ static fn ($prop) => self::permissionToClient($prop->key, $prop->value)
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Add permission for the specific file.
+ *
+ * @param string $fileId the file identifier
+ *
+ * @return JsonResponse
+ */
+ public function createPermission($fileId)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputItem($fileId, self::OWNER);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ // Validate/format input
+ $v = Validator::make(request()->all(), [
+ 'user' => 'email|required',
+ 'permissions' => 'string|required',
+ ]);
+
+ $errors = $v->fails() ? $v->errors()->toArray() : [];
+
+ $acl = self::inputAcl(request()->input('permissions'));
+
+ if (empty($errors['permissions']) && empty($acl)) {
+ $errors['permissions'] = self::trans('validation.file-perm-invalid');
+ }
+
+ $user = \strtolower(request()->input('user'));
+
+ // Check if it already exists
+ if (empty($errors['user'])) {
+ if ($file->properties()->whereLike('key', 'share-%')->whereLike('value', "{$user}:%")->exists()) {
+ $errors['user'] = self::trans('validation.file-perm-exists');
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ // Create the property (with a unique id)
+ while ($shareId = 'share-' . Utils::uuidStr()) {
+ if (!Property::where('key', $shareId)->exists()) {
+ break;
+ }
+ }
+
+ $file->setProperty($shareId, "{$user}:{$acl}");
+
+ $result = self::permissionToClient($shareId, "{$user}:{$acl}");
+
+ return response()->json($result + [
+ 'status' => 'success',
+ 'message' => self::trans('app.file-permissions-create-success'),
+ ]);
+ }
+
+ /**
+ * Delete file permission.
+ *
+ * @param string $fileId the file identifier
+ * @param string $id the file permission identifier
+ *
+ * @return JsonResponse
+ */
+ public function deletePermission($fileId, $id)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputItem($fileId, self::OWNER);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $property = $file->properties()->where('key', $id)->first();
+
+ if (!$property) {
+ return $this->errorResponse(404);
+ }
+
+ $property->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.file-permissions-delete-success'),
+ ]);
+ }
+
+ /**
+ * Update file permission.
+ *
+ * @param Request $request the API request
+ * @param string $fileId the file identifier
+ * @param string $id the file permission identifier
+ *
+ * @return JsonResponse
+ */
+ public function updatePermission(Request $request, $fileId, $id)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputItem($fileId, self::OWNER);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $property = $file->properties()->where('key', $id)->first();
+
+ if (!$property) {
+ return $this->errorResponse(404);
+ }
+
+ // Validate/format input
+ $v = Validator::make($request->all(), [
+ 'user' => 'email|required',
+ 'permissions' => 'string|required',
+ ]);
+
+ $errors = $v->fails() ? $v->errors()->toArray() : [];
+
+ $acl = self::inputAcl($request->input('permissions'));
+
+ if (empty($errors['permissions']) && empty($acl)) {
+ $errors['permissions'] = self::trans('validation.file-perm-invalid');
+ }
+
+ $user = \strtolower($request->input('user'));
+
+ if (empty($errors['user']) && !str_starts_with($property->value, "{$user}:")) {
+ if ($file->properties()->whereLike('key', 'share-%')->whereLike('value', "{$user}:%")->exists()) {
+ $errors['user'] = self::trans('validation.file-perm-exists');
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $property->value = "{$user}:{$acl}";
+ $property->save();
+
+ $result = self::permissionToClient($property->key, $property->value);
+
+ return response()->json($result + [
+ 'status' => 'success',
+ 'message' => self::trans('app.file-permissions-update-success'),
+ ]);
+ }
+
+ /**
+ * Convert Permission to an array for the API response.
+ *
+ * @param string $id Permission identifier
+ * @param string $value Permission record
+ *
+ * @return array Permission data
+ */
+ protected static function permissionToClient(string $id, string $value): array
+ {
+ [$user, $acl] = explode(':', $value);
+
+ $perms = str_contains($acl, self::WRITE) ? 'read-write' : 'read-only';
+
+ return [
+ 'id' => $id,
+ 'user' => $user,
+ 'permissions' => $perms,
+ 'link' => Utils::serviceUrl('file/' . $id),
+ ];
+ }
+
+ /**
+ * Convert ACL label into internal permissions spec.
+ *
+ * @param string $acl Access rights label
+ *
+ * @return ?string Permissions ('r' or 'rw')
+ */
+ protected static function inputAcl($acl): ?string
+ {
+ // The ACL widget supports 'full', 'read-write', 'read-only',
+ if ($acl == 'read-write') {
+ return self::READ . self::WRITE;
+ }
+
+ if ($acl == 'read-only') {
+ return self::READ;
+ }
+
+ return null;
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Fs/SharesTrait.php b/src/app/Http/Controllers/API/V4/Fs/SharesTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Fs/SharesTrait.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Fs;
+
+use App\Fs\Item;
+use App\Fs\Share;
+use App\Http\Resources\FsShareResource;
+use App\User;
+use Dedoc\Scramble\Attributes\BodyParameter;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+
+trait SharesTrait
+{
+ /**
+ * List collection shares
+ *
+ * @param string $itemId Collection identifier
+ */
+ public function getShares($itemId): JsonResponse
+ {
+ $item = Item::find($itemId);
+
+ if (!$item) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$item->isCollection() || $item->user_id != $this->guard()->user()->id) {
+ return $this->errorResponse(403);
+ }
+
+ $result = $item->sharesList();
+
+ return response()->json([
+ // List of collection shares
+ 'list' => FsShareResource::collection($result->values()),
+ // @var int Number of records in the result
+ 'count' => $result->count(),
+ ]);
+ }
+
+ /**
+ * Update collection shares
+ *
+ * @param Request $request Request
+ * @param string $itemId Collection identifier
+ */
+ #[BodyParameter('shares', description: 'Shares list', type: 'array', required: true)]
+ public function setShares(Request $request, $itemId): JsonResponse
+ {
+ $item = Item::find($itemId);
+
+ if (!$item) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$item->isCollection() || $item->user_id != $this->guard()->user()->id) {
+ return $this->errorResponse(403);
+ }
+
+ $existing_shares = $item->sharesList();
+ $for_update = [];
+ $for_create = [];
+ $errors = [];
+
+ // Process the input
+ foreach ((array) $request->shares as $idx => $share) {
+ if (!is_array($share) || empty($share['sharee']) || !str_contains($share['sharee'], '@')) {
+ $errors[$idx] = self::trans('validation.collection-shares-invalid-sharee');
+ continue;
+ }
+ if (empty($share['rights']) || !in_array($share['rights'], ['read-only', 'read-write'])) {
+ $errors[$idx] = self::trans('validation.collection-shares-invalid-rights');
+ continue;
+ }
+
+ $rights = $share['rights'] == 'read-only' ? Share::READ : (Share::READ + Share::WRITE);
+
+ if ($existing = $existing_shares[$share['sharee']] ?? null) {
+ if ($existing->rights != $rights) {
+ $existing->rights = $rights;
+ $for_update[] = $existing;
+ }
+ unset($existing_shares[$share['sharee']]);
+ } else {
+ // TODO: Groups support
+ $sharee = User::where('email', $share['sharee'])->first();
+
+ if ($sharee) {
+ $for_create[] = [
+ 'sharee_id' => $sharee->id,
+ 'sharee_type' => $sharee::class,
+ 'rights' => $rights,
+ ];
+ } else {
+ $errors[$idx] = self::trans('validation.collection-shares-invalid-sharee');
+ }
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => ['shares' => $errors]], 422);
+ }
+
+ // Apply changes
+ DB::beginTransaction();
+
+ foreach ($for_update as $share) {
+ $share->save();
+ }
+
+ foreach ($for_create as $share) {
+ $item->shares()->create($share);
+ }
+
+ foreach ($existing_shares as $share) {
+ $share->delete();
+ }
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => self::trans('app.collection-shares-update-success'),
+ ]);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/FsController.php b/src/app/Http/Controllers/API/V4/FsController.php
--- a/src/app/Http/Controllers/API/V4/FsController.php
+++ b/src/app/Http/Controllers/API/V4/FsController.php
@@ -4,11 +4,13 @@
use App\Fs\Item;
use App\Fs\Property;
+use App\Fs\Share;
use App\Http\Controllers\RelationController;
use App\Http\Resources\FsItemInfoResource;
use App\Http\Resources\FsItemResource;
use App\Rules\FileName;
use App\Support\Facades\Storage;
+use App\User;
use App\Utils;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Http\JsonResponse;
@@ -21,8 +23,13 @@
class FsController extends RelationController
{
+ use Fs\PermissionsTrait;
+ use Fs\SharesTrait;
+
protected const READ = 'r';
protected const WRITE = 'w';
+ protected const DELETE = 'd';
+ protected const OWNER = 'o';
/** @var string Resource localization label */
protected $label = 'file';
@@ -38,7 +45,7 @@
public function destroy($id): JsonResponse
{
// Only the file owner can do that, for now
- $file = $this->inputItem($id, null);
+ $file = $this->inputItem($id, self::DELETE);
if (is_int($file)) {
return $this->errorResponse($file);
@@ -64,6 +71,8 @@
* @param string $id the download (not file) identifier
*
* @return Response|StreamedResponse
+ *
+ * @unauthenticated
*/
public function download($id)
{
@@ -82,186 +91,6 @@
return Storage::fileDownload($file);
}
- /**
- * Fetch the permissions for the specific file.
- *
- * @param string $fileId the file identifier
- *
- * @return JsonResponse
- */
- public function getPermissions($fileId)
- {
- // Only the file owner can do that, for now
- $file = $this->inputItem($fileId, null);
-
- if (is_int($file)) {
- return $this->errorResponse($file);
- }
-
- $result = $file->properties()->whereLike('key', 'share-%')->get()->map(
- static fn ($prop) => self::permissionToClient($prop->key, $prop->value)
- );
-
- $result = [
- 'list' => $result,
- 'count' => count($result),
- ];
-
- return response()->json($result);
- }
-
- /**
- * Add permission for the specific file.
- *
- * @param string $fileId the file identifier
- *
- * @return JsonResponse
- */
- public function createPermission($fileId)
- {
- // Only the file owner can do that, for now
- $file = $this->inputItem($fileId, null);
-
- if (is_int($file)) {
- return $this->errorResponse($file);
- }
-
- // Validate/format input
- $v = Validator::make(request()->all(), [
- 'user' => 'email|required',
- 'permissions' => 'string|required',
- ]);
-
- $errors = $v->fails() ? $v->errors()->toArray() : [];
-
- $acl = self::inputAcl(request()->input('permissions'));
-
- if (empty($errors['permissions']) && empty($acl)) {
- $errors['permissions'] = self::trans('validation.file-perm-invalid');
- }
-
- $user = \strtolower(request()->input('user'));
-
- // Check if it already exists
- if (empty($errors['user'])) {
- if ($file->properties()->whereLike('key', 'share-%')->whereLike('value', "{$user}:%")->exists()) {
- $errors['user'] = self::trans('validation.file-perm-exists');
- }
- }
-
- if (!empty($errors)) {
- return response()->json(['status' => 'error', 'errors' => $errors], 422);
- }
-
- // Create the property (with a unique id)
- while ($shareId = 'share-' . Utils::uuidStr()) {
- if (!Property::where('key', $shareId)->exists()) {
- break;
- }
- }
-
- $file->setProperty($shareId, "{$user}:{$acl}");
-
- $result = self::permissionToClient($shareId, "{$user}:{$acl}");
-
- return response()->json($result + [
- 'status' => 'success',
- 'message' => self::trans('app.file-permissions-create-success'),
- ]);
- }
-
- /**
- * Delete file permission.
- *
- * @param string $fileId the file identifier
- * @param string $id the file permission identifier
- *
- * @return JsonResponse
- */
- public function deletePermission($fileId, $id)
- {
- // Only the file owner can do that, for now
- $file = $this->inputItem($fileId, null);
-
- if (is_int($file)) {
- return $this->errorResponse($file);
- }
-
- $property = $file->properties()->where('key', $id)->first();
-
- if (!$property) {
- return $this->errorResponse(404);
- }
-
- $property->delete();
-
- return response()->json([
- 'status' => 'success',
- 'message' => self::trans('app.file-permissions-delete-success'),
- ]);
- }
-
- /**
- * Update file permission.
- *
- * @param Request $request the API request
- * @param string $fileId the file identifier
- * @param string $id the file permission identifier
- *
- * @return JsonResponse
- */
- public function updatePermission(Request $request, $fileId, $id)
- {
- // Only the file owner can do that, for now
- $file = $this->inputItem($fileId, null);
-
- if (is_int($file)) {
- return $this->errorResponse($file);
- }
-
- $property = $file->properties()->where('key', $id)->first();
-
- if (!$property) {
- return $this->errorResponse(404);
- }
-
- // Validate/format input
- $v = Validator::make($request->all(), [
- 'user' => 'email|required',
- 'permissions' => 'string|required',
- ]);
-
- $errors = $v->fails() ? $v->errors()->toArray() : [];
-
- $acl = self::inputAcl($request->input('permissions'));
-
- if (empty($errors['permissions']) && empty($acl)) {
- $errors['permissions'] = self::trans('validation.file-perm-invalid');
- }
-
- $user = \strtolower($request->input('user'));
-
- if (empty($errors['user']) && !str_starts_with($property->value, "{$user}:")) {
- if ($file->properties()->whereLike('key', 'share-%')->whereLike('value', "{$user}:%")->exists()) {
- $errors['user'] = self::trans('validation.file-perm-exists');
- }
- }
-
- if (!empty($errors)) {
- return response()->json(['status' => 'error', 'errors' => $errors], 422);
- }
-
- $property->value = "{$user}:{$acl}";
- $property->save();
-
- $result = self::permissionToClient($property->key, $property->value);
-
- return response()->json($result + [
- 'status' => 'success',
- 'message' => self::trans('app.file-permissions-update-success'),
- ]);
- }
-
/**
* Listing of files and folders.
*/
@@ -273,23 +102,49 @@
{
$search = trim(request()->input('search'));
$page = (int) (request()->input('page')) ?: 1;
- $parent = request()->input('parent');
+ $parent_id = request()->input('parent');
$type = request()->input('type');
$pageSize = 100;
$hasMore = false;
+ $user = $this->guard()->user();
+
+ if ($parent_id) {
+ $parent = Item::find($parent_id);
+
+ if (!$parent || !$parent->isCollection() || !$parent->isReadable($user)) {
+ return $this->errorResponse(403);
+ }
+ }
- $result = $this->guard()->user()->fsItems()
- ->select('fs_items.*', 'fs_properties.value as name')
+ $result = Item::select('fs_items.*', 'fs_properties.value as name', 'users.email')
->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
+ ->leftJoin('users', 'users.id', '=', 'fs_items.user_id')
->where('key', 'name')
->whereNot('type', '&', Item::TYPE_INCOMPLETE);
- if ($parent) {
+ if (!empty($parent)) {
+ // Get direct children of the requested folder
$result->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
- ->where('fs_relations.item_id', $parent);
+ ->where('fs_relations.item_id', $parent->id);
} else {
- $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
- ->whereNull('fs_relations.related_id');
+ $result->leftJoin('fs_relations', 'fs_relations.related_id', '=', 'fs_items.id')
+ ->leftJoin('fs_shares', 'fs_shares.item_id', '=', 'fs_items.id')
+ // Get both owned (top-level) and shared items
+ // FIXME: Should we, instead of all shared folders, rather make "virtual" user root folders?
+ // They then could be used to list folders shared by specified user?
+ // Creating "virtual" folders to access non-top-level shared folders would be hard/slow.
+ ->where(function ($query) use ($user) {
+ $query->where(function ($query) use ($user) {
+ $query->where('user_id', $user->id)
+ ->whereNull('fs_relations.related_id');
+ })
+ ->orWhere(function ($query) use ($user) {
+ // TODO: Groups support
+ $query->where('sharee_id', $user->id)
+ ->where('sharee_type', $user::class)
+ ->where('rights', '&', Share::READ);
+ });
+ });
}
// Additional properties
@@ -364,7 +219,7 @@
}
/**
- * Create a new file.
+ * Create a new file/folder.
*
* @param Request $request the API request
*
@@ -385,7 +240,7 @@
}
$parents = $this->getInputParents($request);
- if ($errorResponse = $this->validateParents($parents)) {
+ if ($errorResponse = $this->validateParents($parents, null, $owner)) {
return $errorResponse;
}
@@ -409,7 +264,7 @@
DB::beginTransaction();
- $file = $this->deduplicateOrCreate($request, Item::TYPE_INCOMPLETE | Item::TYPE_FILE);
+ $file = $this->deduplicateOrCreate($request, Item::TYPE_INCOMPLETE | Item::TYPE_FILE, $owner);
$file->setProperties($properties);
if (!empty($parents)) {
@@ -446,38 +301,29 @@
}
/**
- * Update a file.
+ * Update a file/folder.
*
* @param Request $request the API request
* @param string $id File identifier
*
* @return JsonResponse The response
*/
+ #[QueryParameter('media', description: 'Request type (metadata, content, resumable)', type: 'string')]
public function update(Request $request, $id)
{
- $file = $this->inputItem($id, self::WRITE);
+ $media = $request->input('media') ?: 'metadata';
+ $file = $this->inputItem($id, $media == 'metadata' ? self::DELETE : self::WRITE);
if (is_int($file)) {
return $this->errorResponse($file);
}
- if ($file->isCollection()) {
- // Updating a collection is not supported yet
- return $this->errorResponse(405);
- }
-
- $media = $request->input('media') ?: 'metadata';
-
if ($media == 'metadata') {
- $filename = $request->input('name');
-
- // Validate file name input
- if ($filename != $file->getProperty('name')) {
- $v = Validator::make($request->all(), ['name' => [new FileName()]]);
+ // Validate metadata input
+ $v = Validator::make($request->all(), $rules = ['name' => [new FileName()]]);
- if ($v->fails()) {
- return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
- }
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$parents = [
@@ -490,7 +336,7 @@
foreach (array_keys($parents) as $header) {
if ($value = $request->headers->get($header, null)) {
$list = explode(',', $value);
- if ($errorResponse = $this->validateParents($list)) {
+ if ($errorResponse = $this->validateParents($list, $file)) {
return $errorResponse;
}
$parents[$header] = $list;
@@ -509,12 +355,15 @@
$file->parents()->detach($parents['X-Kolab-Remove-Parents']);
}
- if ($filename != $file->getProperty('name')) {
- $file->setProperty('name', $filename);
+ foreach (array_keys($rules) as $prop_name) {
+ $prop_value = (string) $request->input($prop_name);
+ if ($prop_value !== '' && $prop_value != $file->getProperty($prop_name)) {
+ $file->setProperty($prop_name, $prop_value);
+ }
}
DB::commit();
- } elseif ($media == 'resumable' || $media == 'content') {
+ } elseif ($file->isFile() && ($media == 'resumable' || $media == 'content')) {
$params = [];
if ($media == 'resumable') {
@@ -538,7 +387,7 @@
if ($media == 'metadata' || !empty($response['id'])) {
$response += $this->objectToClient($file, true);
- $response['message'] = self::trans('app.file-update-success');
+ $response['message'] = self::trans($file->isCollection() ? 'app.collection-update-success' : 'app.file-update-success');
}
return response()->json($response);
@@ -551,6 +400,8 @@
* @param string $id Upload (not file) identifier
*
* @return JsonResponse The response
+ *
+ * @unauthenticated
*/
public function upload(Request $request, $id)
{
@@ -597,7 +448,7 @@
}
$parents = $this->getInputParents($request);
- if ($errorResponse = $this->validateParents($parents)) {
+ if ($errorResponse = $this->validateParents($parents, null, $owner)) {
return $errorResponse;
}
@@ -621,7 +472,7 @@
DB::beginTransaction();
- $item = $this->deduplicateOrCreate($request, Item::TYPE_COLLECTION);
+ $item = $this->deduplicateOrCreate($request, Item::TYPE_COLLECTION, $owner);
$item->setProperties($properties);
if (!empty($parents)) {
@@ -640,9 +491,8 @@
/**
* Find or create an item, using deduplicate parameters
*/
- protected function deduplicateOrCreate(Request $request, $type): Item
+ protected function deduplicateOrCreate(Request $request, $type, User $user): Item
{
- $user = $this->guard()->user();
$item = null;
if ($request->has('deduplicate-property')) {
@@ -666,64 +516,21 @@
return $item;
}
- /**
- * Convert Permission to an array for the API response.
- *
- * @param string $id Permission identifier
- * @param string $value Permission record
- *
- * @return array Permission data
- */
- protected static function permissionToClient(string $id, string $value): array
- {
- [$user, $acl] = explode(':', $value);
-
- $perms = str_contains($acl, self::WRITE) ? 'read-write' : 'read-only';
-
- return [
- 'id' => $id,
- 'user' => $user,
- 'permissions' => $perms,
- 'link' => Utils::serviceUrl('file/' . $id),
- ];
- }
-
- /**
- * Convert ACL label into internal permissions spec.
- *
- * @param string $acl Access rights label
- *
- * @return ?string Permissions ('r' or 'rw')
- */
- protected static function inputAcl($acl): ?string
- {
- // The ACL widget supports 'full', 'read-write', 'read-only',
- if ($acl == 'read-write') {
- return self::READ . self::WRITE;
- }
-
- if ($acl == 'read-only') {
- return self::READ;
- }
-
- return null;
- }
-
/**
* Get the input file object, check permissions
*
- * @param string $fileId File or file permission identifier
- * @param ?string $permission Required access rights
+ * @param string $fileId File or file permission identifier
+ * @param string $permission Required access rights (self::READ, self::WRITE, self::DELETE)
*
* @return Item|int File object or error code
*/
protected function inputItem($fileId, $permission)
{
$user = $this->guard()->user();
- $isShare = str_starts_with($fileId, 'share-');
+ $isSharedLink = str_starts_with($fileId, 'share-');
// Access via file permission identifier
- if ($isShare) {
+ if ($isSharedLink) {
$property = Property::where('key', $fileId)->first();
if (!$property) {
@@ -732,7 +539,7 @@
[$acl_user, $acl] = explode(':', $property->value);
- if (!$permission || $acl_user != $user->email || !str_contains($acl, $permission)) {
+ if ($acl_user != $user->email || !str_contains($acl, $permission)) {
return 403;
}
@@ -745,11 +552,17 @@
return 404;
}
- if (!$isShare && $user->id != $file->user_id) {
- return 403;
+ $has_access = true;
+ if (!$isSharedLink && $user->id != $file->user_id) {
+ $has_access = match ($permission) {
+ self::READ => $file->isReadable($user),
+ self::WRITE => $file->isEditable($user),
+ self::DELETE => $file->isEditable($user),
+ default => false,
+ };
}
- return $file;
+ return $has_access ? $file : 403;
}
/**
@@ -786,16 +599,54 @@
}
/**
- * Validate parents list
+ * Validate parents list, check permissions on parents
*/
- protected function validateParents($parents)
+ protected function validateParents($parents, $self_item = null, &$owner = null)
{
$user = $this->guard()->user();
- if (!empty($parents) && count($parents) != $user->fsItems()->whereIn('id', $parents)->count()) {
+
+ if (empty($parents)) {
+ $owner = $user;
+ return null;
+ }
+
+ // Cannot be a parent of itself
+ if ($self_item && in_array($self_item->id, $parents)) {
+ $error = self::trans('validation.fsparentself');
+ return response()->json(['status' => 'error', 'errors' => [$error]], 422);
+ }
+
+ // Parents have to be collections
+ $items = Item::whereIn('id', $parents)
+ ->where('type', '&', Item::TYPE_COLLECTION)
+ ->get();
+
+ if (count($parents) != $items->count()) {
$error = self::trans('validation.fsparentunknown');
return response()->json(['status' => 'error', 'errors' => [$error]], 422);
}
+ $owners = [];
+
+ // Check permissions
+ foreach ($items as $item) {
+ if (!$item->isWritable($user)) {
+ return $this->errorResponse(403);
+ }
+
+ $owners[] = $item->user_id;
+ }
+
+ // All parents need to have the same owner, it will become an owner
+ // of the newly created file/collection.
+ if (count(array_unique($owners)) > 1) {
+ $error = self::trans('validation.fsparentmismatch');
+ return response()->json(['status' => 'error', 'errors' => [$error]], 422);
+ }
+
+ $owner_id = array_first($owners);
+ $owner = $owner_id == $user->id ? $user : User::find($owner_id);
+
return null;
}
diff --git a/src/app/Http/Resources/FsItemInfoResource.php b/src/app/Http/Resources/FsItemInfoResource.php
--- a/src/app/Http/Resources/FsItemInfoResource.php
+++ b/src/app/Http/Resources/FsItemInfoResource.php
@@ -20,16 +20,17 @@
*/
public function toArray(Request $request): array
{
- // TODO: Handle read-write/full access rights
- $isOwner = Auth::guard()->user()->id == $this->resource->user_id;
+ $user = Auth::guard()->user();
+ $isOwner = $user->id == $this->resource->user_id;
+ $canUpdate = $canDelete = $this->resource->isEditable($user);
- $parent = $this->resource->parents()->first();
+ $parent = $this->resource->parents->first();
return [
$this->merge(parent::toArray($request)),
- 'canUpdate' => $isOwner,
- 'canDelete' => $isOwner,
+ 'canDelete' => $canDelete,
+ 'canUpdate' => $canUpdate,
'isOwner' => $isOwner,
// @var string Unauthenticated doawnload location for the file content
diff --git a/src/app/Http/Resources/FsItemResource.php b/src/app/Http/Resources/FsItemResource.php
--- a/src/app/Http/Resources/FsItemResource.php
+++ b/src/app/Http/Resources/FsItemResource.php
@@ -4,6 +4,7 @@
use App\Fs\Item;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
/**
* Filesystem item response
@@ -21,6 +22,9 @@
*/
public function toArray(Request $request): array
{
+ $user = Auth::guard()->user();
+ $is_shared = $user->id != $this->resource->user_id;
+
$is_file = false;
if ($this->resource->isCollection()) {
$type = self::TYPE_COLLECTION;
@@ -41,6 +45,10 @@
}
}
+ if ($is_shared) {
+ $owner = $this->resource->email ?? $this->resource->user->email;
+ }
+
return [
// @var string Item identifier
'id' => $this->resource->id,
@@ -57,6 +65,9 @@
'size' => $this->when($is_file, (int) ($props['size'] ?? 0)),
// @var string File content type
'mimetype' => $this->when($is_file && isset($props['mimetype']), $props['mimetype'] ?? ''),
+
+ // @var string File owner (for shared items)
+ 'owner' => $this->when($is_shared, $owner ?? null),
];
}
}
diff --git a/src/app/Http/Resources/FsShareResource.php b/src/app/Http/Resources/FsShareResource.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Resources/FsShareResource.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Http\Resources;
+
+use App\Fs\Share;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+
+/**
+ * Filesystem share response
+ *
+ * @mixin Share
+ */
+class FsShareResource extends ApiResource
+{
+ /**
+ * Transform the resource into an array.
+ */
+ public function toArray(Request $request): array
+ {
+ // Note: For now the ACL widget supports only two permission labels
+ if ($this->resource->rights & Share::WRITE) {
+ $rights = 'read-write';
+ } else {
+ $rights = 'read-only';
+ }
+
+ return [
+ // @var string Item identifier
+ 'id' => $this->resource->id,
+ // @var string Rights
+ 'rights' => $rights,
+ // @var string Sharee (email address)
+ 'sharee' => $this->resource->email,
+ // @var string Sharee type
+ 'sharee_type' => Str::of($this->resource->sharee_type)->classBasename()->lower(),
+ // @var string<date-time> Creation time
+ 'created_at' => $this->resource->created_at->toDateTimeString(),
+ // @var string<date-time> Last update time
+ 'updated_at' => $this->resource->updated_at->toDateTimeString(),
+ ];
+ }
+}
diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php
--- a/src/app/Observers/GroupObserver.php
+++ b/src/app/Observers/GroupObserver.php
@@ -49,6 +49,11 @@
}
DeleteJob::dispatch($group->id);
+
+ \App\Fs\Share::where([
+ 'sharee_id' => $group->id,
+ 'sharee_type' => Group::class,
+ ])->delete();
}
/**
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
@@ -294,13 +294,16 @@
EventLog::where('object_id', $user->id)->where('object_type', User::class)->delete();
}
- // regardless of force delete, we're always purging whitelists... just in case
- Whitelist::where(
- [
- 'whitelistable_id' => $user->id,
- 'whitelistable_type' => User::class,
- ]
- )->delete();
+ // Regardless of force delete, we're always pruning some stuff... just in case
+ Whitelist::where([
+ 'whitelistable_id' => $user->id,
+ 'whitelistable_type' => User::class,
+ ])->delete();
+
+ \App\Fs\Share::where([
+ 'sharee_id' => $user->id,
+ 'sharee_type' => User::class,
+ ])->delete();
}
/**
diff --git a/src/app/Rules/FileName.php b/src/app/Rules/FileName.php
--- a/src/app/Rules/FileName.php
+++ b/src/app/Rules/FileName.php
@@ -33,13 +33,17 @@
return false;
}
- // Leading/trailing spaces, or all spaces
- if (preg_match('|^\s+$|', $name) || preg_match('|^\s+|', $name) || preg_match('|\s+$|', $name)) {
+ // Forbidden names
+ if (in_array($name, ['.', '..'])) {
$this->message = \trans('validation.file-name-invalid');
return false;
}
- // FIXME: Should we require a dot?
+ // Leading/trailing spaces, or all spaces
+ if (preg_match('/^\s+/', $name) || preg_match('/\s+$/', $name)) {
+ $this->message = \trans('validation.file-name-invalid');
+ return false;
+ }
return true;
}
diff --git a/src/database/migrations/2026_02_19_100000_create_fs_shares_table.php b/src/database/migrations/2026_02_19_100000_create_fs_shares_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2026_02_19_100000_create_fs_shares_table.php
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'fs_shares',
+ static function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('item_id', 36);
+ $table->tinyInteger('rights')->unsigned();
+ $table->bigInteger('sharee_id');
+ $table->string('sharee_type');
+ $table->timestamps();
+
+ $table->unique(['sharee_id', 'sharee_type', 'item_id']);
+
+ $table->foreign('item_id')->references('id')->on('fs_items')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('fs_shares');
+ }
+};
diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js
--- a/src/resources/js/bootstrap.js
+++ b/src/resources/js/bootstrap.js
@@ -29,7 +29,7 @@
import BtnRouter from '../vue/Widgets/BtnRouter'
import Tabs from '../vue/Widgets/Tabs'
import Toast from '../vue/Widgets/Toast'
-import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import { Tooltip } from 'bootstrap'
window.Vue = Vue
@@ -37,6 +37,7 @@
Vue.component('Btn', Btn)
Vue.component('BtnRouter', BtnRouter)
Vue.component('SvgIcon', FontAwesomeIcon)
+Vue.component('SvgIconLayer', FontAwesomeLayers)
Vue.component('Tabs', Tabs)
const vTooltip = (el, binding) => {
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
@@ -102,6 +102,7 @@
'collection-create-success' => 'Collection created successfully.',
'collection-delete-success' => 'Collection deleted successfully.',
'collection-update-success' => 'Collection updated successfully.',
+ 'collection-shares-update-success' => 'Collection shares updated successfully.',
'payment-status-paid' => 'The payment has been completed successfully.',
'payment-status-canceled' => 'The payment has been canceled.',
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
@@ -21,6 +21,7 @@
'btn' => [
'actions' => "Actions",
+ 'actions-menu' => "Additional actions",
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
@@ -52,6 +53,9 @@
],
'collection' => [
+ 'acl-text' => "You can give permissions to your collection to an existing user."
+ . "This will give the user read-only or read-write access to all content in the collection,"
+ . " including files and other collections.",
'create' => "Create collection",
'new' => "New Collection",
'name' => "Name",
@@ -178,6 +182,7 @@
'mimetype' => "Mimetype",
'mtime' => "Modified",
'new' => "New file",
+ 'rename' => "Rename",
'search' => "File name",
'sharing' => "Sharing",
'sharing-links-text' => "You can share the file with other users by giving them read-only access "
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
@@ -83,7 +83,7 @@
'max' => [
'numeric' => 'The :attribute may not be greater than :max.',
'file' => 'The :attribute may not be greater than :max kilobytes.',
- 'string' => 'The :attribute may not be greater than :max characters.',
+ 'string' => 'The :attribute may not be longer than :max characters.',
'array' => 'The :attribute may not have more than :max items.',
],
'mac_address' => 'The :attribute must be a valid MAC address.',
@@ -163,7 +163,11 @@
'file-name-exists' => 'The file name already exists.',
'file-name-invalid' => 'The file name is invalid.',
'file-name-toolong' => 'The file name is too long.',
+ 'collection-shares-invalid-sharee' => 'Invalid sharee.',
+ 'collection-shares-invalid-rights' => 'Invalid sharee rights.',
'fsparentunknown' => 'Specified parent does not exist.',
+ 'fsparentmismatch' => 'Specified parents do not have the same owner.',
+ 'fsparentself' => 'Cannot assign self as a parent.',
'geolockinerror' => 'The request location is not allowed.',
'ipolicy-invalid' => 'The specified invitation policy is invalid.',
'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
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
@@ -217,7 +217,7 @@
*/
td.buttons,
th.buttons {
- width: 50px;
+ width: 4.5em;
}
}
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
@@ -261,3 +261,10 @@
text-overflow: ellipsis;
}
}
+
+div.folder-icon {
+ & > svg + svg {
+ color:white;
+ transform: scale(0.6) scaleX(-1);
+ }
+}
diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue
--- a/src/resources/vue/File/List.vue
+++ b/src/resources/vue/File/List.vue
@@ -25,7 +25,7 @@
</tr>
</thead>
<tbody>
- <tr v-if="collection.id">
+ <tr v-if="collection.id" @click="$root.clickRecord">
<td colspan="3" class="name">
<router-link :to="'/files' + (collection.parentId ? '/' + collection.parentId : '')">
<svg-icon icon="folder" class="me-1"></svg-icon> ..
@@ -34,17 +34,25 @@
</tr>
<tr v-for="file in files" :key="file.id" @click="$root.clickRecord">
<td class="name">
- <router-link :to="(file.type === 'collection' ? '/files/' : '/file/') + `${file.id}`">
- <svg-icon :icon="fileIcon(file)" class="me-1" style="width:1em"></svg-icon>
- {{ file.name }}
+ <router-link v-if="file.type == 'collection'" :to="`/files/${file.id}`">
+ <folder-icon class="me-1" :folder="file"></folder-icon>
+ {{ file.name }} <span v-if="file.owner">({{ file.owner }})</span>
</router-link>
+ <span v-else>
+ <svg-icon :icon="fileIcon(file)" class="me-1" style="width:1em"></svg-icon> {{ file.name }}
+ </span>
</td>
<td class="size">
<span>{{ fileSize(file) }}</span>
</td>
- <td class="buttons">
- <btn v-if="file.type !== 'collection'" class="button-download p-0 ms-1" @click="fileDownload(file)" icon="download" :title="$t('btn.download')"></btn>
+ <td class="buttons dropdown">
+ <btn v-if="file.type != 'collection'" class="button-download p-0 ms-1" @click="fileDownload(file)" icon="download" :title="$t('btn.download')"></btn>
<btn class="button-delete text-danger p-0 ms-1" @click="fileDelete(file)" icon="trash-can" :title="$t('btn.delete')"></btn>
+ <btn class="button-actions p-0 ms-1" style="width:1em" icon="ellipsis-vertical" :title="$t('btn.actions-menu')" data-bs-toggle="dropdown"></btn>
+ <ul class="dropdown-menu dropdown-menu-end">
+ <li><btn class="dropdown-item" @click="menuActionRename(file)">{{ $t('file.rename') }}</btn></li>
+ <li><btn class="dropdown-item" @click="menuActionShare(file)" :disabled="file.owner">{{ $t('file.sharing') }}</btn></li>
+ </ul>
</td>
</tr>
</tbody>
@@ -59,7 +67,47 @@
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('collection.name') }}</label>
<div class="col-sm-8">
- <input type="text" class="form-control" id="name" v-model="form.name">
+ <input type="text" class="form-control" name="name" v-model="form.name">
+ </div>
+ </div>
+ </div>
+ </modal-dialog>
+ <modal-dialog id="rename-dialog" ref="renameDialog" :title="$t('file.rename')" @click="renameSubmit" :buttons="['submit']">
+ <div>
+ <input type="text" class="form-control" name="name" v-model="form.name">
+ </div>
+ </modal-dialog>
+ <modal-dialog id="shares-dialog" ref="sharesDialogCollection" :title="$t('file.sharing')" @click="shareCollectionSubmit" :buttons="['submit']">
+ <div>
+ <acl-input id="acl" ref="aclInput" class="mb-3" v-model="form.shares" :list="form.shares"
+ :composite="true" :useronly="true" :types="['read-only', 'read-write']"
+ ></acl-input>
+ <small class="form-text">{{ $t('collection.acl-text') }}</small>
+ </div>
+ </modal-dialog>
+ <modal-dialog id="permissions-dialog" ref="permissionsDialog" :title="$t('file.sharing')">
+ <div class="row">
+ <div class="input-group mb-3">
+ <input type="text" class="form-control" id="user" :placeholder="$t('form.email')">
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="permissionAdd">
+ <svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span>
+ </a>
+ </div>
+ <small id="share-links-hint" class="form-text">{{ $t('file.sharing-links-text') }}</small>
+ </div>
+ <div id="share-links" class="row mt-3" v-if="form.shares && form.shares.length">
+ <div class="list-group" style="padding: 0 0.75rem">
+ <div v-for="item in form.shares" :key="item.id" class="list-group-item">
+ <div class="d-flex w-100 justify-content-between">
+ <span class="user lh-lg">
+ <svg-icon icon="user"></svg-icon> {{ item.user }}
+ </span>
+ <span class="d-inline-block">
+ <btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="copyLink(item.link)"></btn>
+ <btn class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="permissionDelete(item.id)"></btn>
+ </span>
+ </div>
+ <code>{{ item.link }}</code>
</div>
</div>
</div>
@@ -68,19 +116,24 @@
</template>
<script>
+ import { Dropdown } from 'bootstrap'
+ import AclInput from '../Widgets/AclInput'
import FileAPI from '../../js/files.js'
+ import FolderIcon from '../Icons/Folder'
import ListTools from '../Widgets/ListTools'
import ModalDialog from '../Widgets/ModalDialog'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
+ require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
require('@fortawesome/free-regular-svg-icons/faFile').definition,
require('@fortawesome/free-regular-svg-icons/faFileAudio').definition,
require('@fortawesome/free-regular-svg-icons/faFileCode').definition,
require('@fortawesome/free-regular-svg-icons/faFileImage').definition,
require('@fortawesome/free-regular-svg-icons/faFileLines').definition,
require('@fortawesome/free-regular-svg-icons/faFileVideo').definition,
+ require('@fortawesome/free-solid-svg-icons/faEllipsisVertical').definition,
require('@fortawesome/free-solid-svg-icons/faFolder').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
require('@fortawesome/free-solid-svg-icons/faUpload').definition,
@@ -96,6 +149,8 @@
export default {
components: {
+ AclInput,
+ FolderIcon,
ModalDialog
},
mixins: [ ListTools ],
@@ -112,7 +167,9 @@
api: {},
collection: {},
collectionId: '',
+ fileDropdown: null,
files: [],
+ menuItem: null,
form: {},
uploads: {},
}
@@ -138,6 +195,9 @@
this.loadFiles({ init: true })
},
methods: {
+ copyLink(link) {
+ navigator.clipboard.writeText(link);
+ },
createCollection() {
this.form = { name: '', type: 'collection', parent: this.collectionId }
this.$root.clearFormValidation($('#collection-dialog'))
@@ -211,9 +271,92 @@
this.listSearch('files', 'api/v4/fs', params)
},
+ menuActionRename(file) {
+ this.form = { itemId: file.id, name: file.name }
+ this.$root.clearFormValidation($('rename-dialog'))
+ this.$refs.renameDialog.show()
+ },
+ menuActionShare(file) {
+ this.form = { itemId: file.id, shares: [] }
+
+ let dialog_id = '#share-dialog'
+ let url = '/api/v4/fs/' + file.id + '/shares'
+
+ if (file.type == 'collection') {
+ this.$refs.sharesDialogCollection.show()
+ } else {
+ dialog_id = '#permissions-dialog'
+ url = 'api/v4/fs/' + file.id + '/permissions'
+ this.$refs.permissionsDialog.show()
+ }
+
+ this.$root.clearFormValidation($(dialog_id))
+
+ this.$nextTick().then(() => {
+ const loader = [dialog_id + ' .modal-body', { 'min-height': '20em', small: false }]
+
+ axios.get(url, { loader })
+ .then(response => {
+ this.form.shares = response.data.list
+ })
+ })
+ },
+ permissionAdd() {
+ let post = { permissions: 'read-only', user: $('#user').val() }
+
+ if (!post.user) {
+ return
+ }
+
+ axios.post('api/v4/fs/' + this.form.itemId + '/permissions', post)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.form.shares.push(response.data)
+ }
+ })
+ },
+ permissionDelete(id) {
+ axios.delete('api/v4/fs/' + this.form.itemId + '/permissions/' + id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$delete(this.form.shares, this.form.shares.findIndex(element => element.id == id))
+ }
+ })
+ },
+ renameSubmit() {
+ this.$root.clearFormValidation($('#rename-dialog'))
+
+ let post = { name: this.form.name }
+
+ axios.put('/api/v4/fs/' + this.form.itemId, post)
+ .then(response => {
+ this.$refs.renameDialog.hide()
+ this.$toast.success(response.data.message)
+
+ // Change the name in the files list
+ // TODO: Should we just refresh the list or re-sort it?
+ const idx = this.files.findIndex(item => item.id == response.data.id)
+ this.$set(this.files, idx, response.data)
+ })
+ },
searchFiles(search) {
this.loadFiles({ reset: true, search })
},
+ shareCollectionSubmit() {
+ this.$root.clearFormValidation($('#shares-dialog'))
+
+ let shares = this.$refs.aclInput.getList().map(elem => {
+ return this.$root.pick(elem, ['sharee', 'rights'])
+ })
+
+ axios.post('/api/v4/fs/' + this.form.itemId + '/shares', { shares })
+ .then(response => {
+ this.$refs.sharesDialogCollection.hide()
+ this.$toast.success(response.data.message)
+ })
+ },
uploadProgressHandler(params) {
// Note: There might be more than one event with completed=0
// e.g. if you upload multiple files at once
diff --git a/src/resources/vue/Icons/Folder.vue b/src/resources/vue/Icons/Folder.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Icons/Folder.vue
@@ -0,0 +1,21 @@
+<template>
+ <svg-icon-layer class="folder-icon" style="width:1em">
+ <svg-icon icon="folder"></svg-icon>
+ <svg-icon icon="share-nodes" v-if="folder.owner"></svg-icon>
+ </svg-icon-layer>
+</template>
+
+<script>
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ library.add(
+ require('@fortawesome/free-solid-svg-icons/faFolder').definition,
+ require('@fortawesome/free-solid-svg-icons/faShareNodes').definition,
+ )
+
+ export default {
+ props: {
+ folder: { type: Object, default: () => {} }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue
--- a/src/resources/vue/Widgets/AclInput.vue
+++ b/src/resources/vue/Widgets/AclInput.vue
@@ -32,6 +32,7 @@
export default {
props: {
+ composite: { type: Boolean, default: false },
list: { type: Array, default: () => [] },
id: { type: String, default: '' },
types: { type: Array, default: () => DEFAULT_TYPES },
@@ -51,19 +52,22 @@
// Users tend to forget about pressing the "plus" button
// Note: We can't use form.onsubmit (too late)
// Note: Use of input.onblur has been proven to be problematic
- // TODO: What with forms that have no submit button?
- $(this.$el).closest('form').find('button[type=submit]').on('click', () => {
- this.updateList()
- this.addItem(false)
- })
+ // Note: For forms that have no submit button or modal dialogs, call getList()
+ $(this.$el).closest('form').find('button[type=submit]').on('click', () => { this.getList() })
userAutocomplete(this.input)
},
methods: {
aclIdent(item) {
+ if (this.composite) {
+ return item.sharee
+ }
return item.split(/\s*,\s*/)[0]
},
aclPerm(item) {
+ if (this.composite) {
+ return item.rights
+ }
return item.split(/\s*,\s*/)[1]
},
addItem(focus) {
@@ -76,7 +80,7 @@
const perm = this.types.length > 1 ? this.perm : this.types[0]
- this.$set(this.list, this.list.length, value + ', ' + perm)
+ this.$set(this.list, this.list.length, this.composeItem(value, perm))
this.input.classList.remove('is-invalid')
this.input.value = ''
@@ -99,6 +103,13 @@
$(this.input)[this.mod == 'user' ? 'removeClass' : 'addClass']('d-none')
$(this.input).prev()[this.mod == 'user' ? 'addClass' : 'removeClass']('mod-user')
},
+ composeItem(value, rights) {
+ if (this.composite) {
+ return { sharee: value, rights }
+ }
+
+ return value + ', ' + rights
+ },
deleteItem(index) {
this.updateList()
this.$delete(this.list, index)
@@ -108,6 +119,12 @@
this.$el.classList.remove('is-invalid')
}
},
+ getList() {
+ this.updateList()
+ this.addItem(false)
+
+ return this.list
+ },
keyDown(e) {
if (e.which == 13 && e.target.value) {
this.addItem()
@@ -119,7 +136,7 @@
$(this.$el).children('.input-group:not(:first-child)').each((index, elem) => {
const perm = this.types.length > 1 ? $(elem).find('select.acl').val() : this.types[0]
const value = $(elem).find('input').val()
- this.$set(this.list, index, value + ', ' + perm)
+ this.$set(this.list, index, this.composeItem(value, perm))
})
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -73,6 +73,9 @@
Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']);
Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']);
+ Route::get('fs/{itemId}/shares', [API\V4\FsController::class, 'getShares']);
+ Route::post('fs/{itemId}/shares', [API\V4\FsController::class, 'setShares']);
+
Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload'])
->withoutMiddleware($middleware)->middleware(['api']);
Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download'])
diff --git a/src/tests/Feature/Controller/Fs/PermissionsTest.php b/src/tests/Feature/Controller/Fs/PermissionsTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Fs/PermissionsTest.php
@@ -0,0 +1,235 @@
+<?php
+
+namespace Tests\Feature\Controller\Fs;
+
+use App\User;
+use App\Utils;
+use Tests\TestCaseFs;
+
+class PermissionsTest extends TestCaseFs
+{
+ /**
+ * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/fs/<file-id>/permissions)
+ */
+ public function testPermissions(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test1.txt', []);
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/fs/{$file->id}/permissions");
+ $response->assertStatus(401);
+ $response = $this->post("api/v4/fs/{$file->id}/permissions", []);
+ $response->assertStatus(401);
+
+ // Non-existing file
+ $response = $this->actingAs($john)->get("api/v4/fs/1234/permissions");
+ $response->assertStatus(404);
+ $response = $this->actingAs($john)->post("api/v4/fs/1234/permissions", []);
+ $response->assertStatus(404);
+
+ // No permissions to the file
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}/permissions");
+ $response->assertStatus(403);
+ $response = $this->actingAs($jack)->post("api/v4/fs/{$file->id}/permissions", []);
+ $response->assertStatus(403);
+
+ // Expect an empty list of permissions
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}/permissions");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame([], $json['list']);
+ $this->assertSame(0, $json['count']);
+
+ // Empty input
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertSame(["The user field is required."], $json['errors']['user']);
+ $this->assertSame(["The permissions field is required."], $json['errors']['permissions']);
+
+ // Test more input validation
+ $post = ['user' => 'user', 'permissions' => 'read'];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertSame(["The user must be a valid email address."], $json['errors']['user']);
+ $this->assertSame("The file permission is invalid.", $json['errors']['permissions']);
+
+ // Let's add some permission
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only'];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions created successfully.", $json['message']);
+
+ $permission = $file->properties()->where('key', 'like', 'share-%')->orderBy('value')->first();
+
+ $this->assertSame("{$jack->email}:r", $permission->value);
+ $this->assertSame($permission->key, $json['id']);
+ $this->assertSame($jack->email, $json['user']);
+ $this->assertSame('read-only', $json['permissions']);
+ $this->assertSame(Utils::serviceUrl('file/' . $permission->key), $json['link']);
+
+ // Error handling on use of the same user
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only'];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("File permission already exists.", $json['errors']['user']);
+
+ // Test update
+ $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/1234", $post);
+ $response->assertStatus(404);
+
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-write'];
+ $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/{$permission->key}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions updated successfully.", $json['message']);
+
+ $permission->refresh();
+
+ $this->assertSame("{$jack->email}:rw", $permission->value);
+ $this->assertSame($permission->key, $json['id']);
+ $this->assertSame($jack->email, $json['user']);
+ $this->assertSame('read-write', $json['permissions']);
+ $this->assertSame(Utils::serviceUrl('file/' . $permission->key), $json['link']);
+
+ // Input validation on update
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read'];
+ $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/{$permission->key}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The file permission is invalid.", $json['errors']['permissions']);
+
+ // Test GET with existing permissions
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}/permissions");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(1, $json['count']);
+
+ $this->assertSame($permission->key, $json['list'][0]['id']);
+ $this->assertSame($jack->email, $json['list'][0]['user']);
+ $this->assertSame('read-write', $json['list'][0]['permissions']);
+ $this->assertSame(Utils::serviceUrl('file/' . $permission->key), $json['list'][0]['link']);
+
+ // Delete permission
+ $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}/permissions/1234");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}/permissions/{$permission->key}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions deleted successfully.", $json['message']);
+
+ $this->assertCount(0, $file->properties()->where('key', 'like', 'share-%')->get());
+ }
+
+ /**
+ * Test reading a shared file
+ */
+ public function testFileRead(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test.txt', 'Test content', ['mimetype' => 'plain/text']);
+
+ // Test acting as a user with file permissions
+ $permission = $this->getTestFilePermission($file, $jack, 'r');
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$permission->key}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $this->assertFalse($json['isOwner']);
+ $this->assertFalse($json['canUpdate']);
+ $this->assertFalse($json['canDelete']);
+
+ // Test downloading the content
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$permission->key}?downloadUrl=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $link = $json['downloadUrl'];
+
+ // Fetch the file content
+ $response = $this->get(substr($link, strpos($link, '/api/') + 1));
+ $response->assertStatus(200)
+ ->assertHeader('Content-Disposition', "attachment; filename=test.txt")
+ ->assertHeader('Content-Type', $file->getProperty('mimetype'));
+
+ $this->assertSame('Test content', $response->streamedContent());
+ }
+
+ /**
+ * Test updating a shared file
+ */
+ public function testFileUpdate(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+
+ // Read-only permission (rename)
+ $permission = $this->getTestFilePermission($file, $jack, 'r');
+ $post = ['name' => 'new name.txt'];
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$permission->key}", $post);
+ $response->assertStatus(403);
+
+ // Read-ony permission (content update)
+ $response = $this->sendRawBody($jack, 'PUT', "api/v4/fs/{$permission->key}?media=content", [], 'new');
+ $response->assertStatus(403);
+
+ // Read-write permission (rename)
+ $permission->value = "{$jack->email}:rw";
+ $permission->save();
+
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$permission->key}", $post);
+ $response->assertStatus(403);
+
+ // Read-write permission (content update)
+ $response = $this->sendRawBody($jack, 'PUT', "api/v4/fs/{$permission->key}?media=content", [], 'new');
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File updated successfully.", $json['message']);
+ $this->assertSame('new', $this->getTestFileContent($file));
+ }
+}
diff --git a/src/tests/Feature/Controller/Fs/SharesTest.php b/src/tests/Feature/Controller/Fs/SharesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Fs/SharesTest.php
@@ -0,0 +1,364 @@
+<?php
+
+namespace Tests\Feature\Controller\Fs;
+
+use App\Fs\Item;
+use App\Fs\Share;
+use Tests\TestCaseFs;
+
+class SharesTest extends TestCaseFs
+{
+ /**
+ * Test fetching/setting folder shares (GET|POST /api/v4/fs/<folder-id>/shares)
+ */
+ public function testShares(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $folder = $this->getTestCollection($john, 'test1');
+ $folder2 = $this->getTestCollection($jack, 'test2');
+ $file = $this->getTestFile($john, 'test1.txt');
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/fs/{$folder->id}/shares");
+ $response->assertStatus(401);
+ $response = $this->post("api/v4/fs/{$folder->id}/shares", []);
+ $response->assertStatus(401);
+
+ // Non-existing folder
+ $response = $this->actingAs($john)->get("api/v4/fs/1234/shares");
+ $response->assertStatus(404);
+ $response = $this->actingAs($john)->post("api/v4/fs/1234/shares", []);
+ $response->assertStatus(404);
+
+ // File not a folder
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}/shares");
+ $response->assertStatus(403);
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/shares", []);
+ $response->assertStatus(403);
+
+ // Folder of another user
+ $response = $this->actingAs($john)->get("api/v4/fs/{$folder2->id}/shares");
+ $response->assertStatus(403);
+ $response = $this->actingAs($john)->post("api/v4/fs/{$folder2->id}/shares", []);
+ $response->assertStatus(403);
+
+ // Expect an empty list of permissions
+ $response = $this->actingAs($john)->get("api/v4/fs/{$folder->id}/shares");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertCount(2, $json);
+ $this->assertSame([], $json['list']);
+ $this->assertSame(0, $json['count']);
+
+ // Empty input
+ $response = $this->actingAs($john)->post("api/v4/fs/{$folder->id}/shares", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Collection shares updated successfully.", $json['message']);
+
+ // Test input validation
+ $post = ['shares' => [['sharee' => 'unknown', 'rights' => 'read-only']]];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$folder->id}/shares", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(["Invalid sharee."], $json['errors']['shares']);
+
+ $post = ['shares' => [['sharee' => 'jack@kolab.org', 'rights' => 're']]];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$folder->id}/shares", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(["Invalid sharee rights."], $json['errors']['shares']);
+
+ // Let's add some shares
+ $post = [
+ 'shares' => [
+ ['sharee' => 'jack@kolab.org', 'rights' => 'read-only'],
+ ['sharee' => 'ned@kolab.org', 'rights' => 'read-write'],
+ ],
+ ];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$folder->id}/shares", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Collection shares updated successfully.", $json['message']);
+
+ $shares = $folder->shares()->get()->keyBy('sharee_id')->all();
+ $this->assertCount(2, $shares);
+ $this->assertSame(Share::READ, $shares[$jack->id]->rights);
+ $this->assertSame(Share::READ + Share::WRITE, $shares[$ned->id]->rights);
+
+ $response = $this->actingAs($john)->get("api/v4/fs/{$folder->id}/shares");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertCount(2, $json);
+ $this->assertSame(2, $json['count']);
+ $this->assertSame($jack->email, $json['list'][0]['sharee']);
+ $this->assertSame('read-only', $json['list'][0]['rights']);
+ $this->assertSame('user', $json['list'][0]['sharee_type']);
+ $this->assertSame($ned->email, $json['list'][1]['sharee']);
+ $this->assertSame('read-write', $json['list'][1]['rights']);
+ $this->assertSame('user', $json['list'][1]['sharee_type']);
+
+ // Let's update some shares
+ $post = [
+ 'shares' => [
+ ['sharee' => 'jack@kolab.org', 'rights' => 'read-write'],
+ ],
+ ];
+ $response = $this->actingAs($john)->post("api/v4/fs/{$folder->id}/shares", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Collection shares updated successfully.", $json['message']);
+
+ $shares = $folder->shares()->get()->keyBy('sharee_id')->all();
+ $this->assertCount(1, $shares);
+ $this->assertSame(Share::READ + Share::WRITE, $shares[$jack->id]->rights);
+ }
+
+ /**
+ * Test creting a file/folder in a shared collection
+ */
+ public function testFileCreate(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $parent = $this->getTestCollection($john, 'Parent');
+
+ // Create a file - no shares
+ $response = $this->sendRawBody($jack, 'POST', "api/v4/fs?name=test.txt&parent={$parent->id}", [], 'test');
+ $response->assertStatus(403);
+
+ // Create a collection - no shares
+ $response = $this->sendRawBody($jack, 'POST', "api/v4/fs?name=test&type=collection&parent={$parent->id}", [], '');
+ $response->assertStatus(403);
+
+ $parent->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ | Share::WRITE,
+ ]);
+
+ // Create a file
+ $response = $this->sendRawBody($jack, 'POST', "api/v4/fs?name=test.txt&parent={$parent->id}", [], '');
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $file = Item::find($json['id']);
+ $this->assertCount(1, $parents = $file->parents()->get());
+ $this->assertSame($parent->id, $parents[0]->id);
+ $this->assertSame($john->id, $file->user_id);
+
+ // Create a collection
+ $response = $this->sendRawBody($jack, 'POST', "api/v4/fs?name=test&type=collection&parent={$parent->id}", [], '');
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $folder = Item::find($json['id']);
+ $this->assertCount(1, $parents = $folder->parents()->get());
+ $this->assertSame($parent->id, $parents[0]->id);
+ $this->assertSame($john->id, $folder->user_id);
+
+ // Test using two accessible parents with different owners
+ $parent2 = $this->getTestCollection($jack, 'Parent');
+ $headers = ['X-Kolab-Parents' => implode(',', [$parent->id, $parent2->id])];
+ $response = $this->sendRawBody($jack, 'POST', "api/v4/fs?name=test3.txt", $headers, 'abc');
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(["Specified parents do not have the same owner."], $json['errors']);
+ }
+
+ /**
+ * Test deleting a file in a shared collection
+ */
+ public function testFileDelete(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestCollection($john, 'test');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+ $folder->children()->attach($file);
+
+ $response = $this->actingAs($jack)->delete("api/v4/fs/{$file->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($jack)->delete("api/v4/fs/{$folder->id}");
+ $response->assertStatus(403);
+
+ $folder->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ + Share::WRITE,
+ ]);
+
+ $response = $this->actingAs($jack)->delete("api/v4/fs/{$file->id}");
+ $response->assertStatus(200);
+ }
+
+ /**
+ * Test listing files/collections against shared collections
+ */
+ public function testFileListing(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder1 = $this->getTestCollection($john, 'test1');
+ $folder2 = $this->getTestCollection($john, 'test2');
+ $folder11 = $this->getTestCollection($jack, 'test11');
+ $folder22 = $this->getTestCollection($jack, 'test22');
+ $file1 = $this->getTestFile($john, 'test1.txt', 'Test', ['mimetype' => 'text/plain']);
+ $file2 = $this->getTestFile($john, 'test2.html', 'Test', ['mimetype' => 'text/html']);
+ $folder1->children()->attach([$file1, $file2]);
+
+ $share = $folder1->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ,
+ ]);
+
+ // Top-level
+ $response = $this->actingAs($jack)->get("api/v4/fs");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(3, $json['count']);
+ $this->assertSame('test1', $json['list'][0]['name']);
+ $this->assertSame($john->email, $json['list'][0]['owner']);
+ $this->assertSame('test11', $json['list'][1]['name']);
+ $this->assertTrue(empty($json['list'][1]['owner']));
+ $this->assertSame('test22', $json['list'][2]['name']);
+ $this->assertTrue(empty($json['list'][2]['owner']));
+
+ // Use the shared folder as a parent
+ $response = $this->actingAs($jack)->get("api/v4/fs?parent={$folder1->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(2, $json['count']);
+ $this->assertSame('test1.txt', $json['list'][0]['name']);
+ $this->assertSame($john->email, $json['list'][0]['owner']);
+ $this->assertSame('text/plain', $json['list'][0]['mimetype']);
+ $this->assertSame('test2.html', $json['list'][1]['name']);
+ $this->assertSame($john->email, $json['list'][1]['owner']);
+ $this->assertSame('text/html', $json['list'][1]['mimetype']);
+
+ // Use other user folder that is not shared
+ $response = $this->actingAs($jack)->get("api/v4/fs?parent={$folder2->id}");
+ $response->assertStatus(403);
+
+ // Check an inclusion of a non-top-level shared folder
+ $folder1->children()->attach($folder2);
+ $folder2->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ | Share::WRITE,
+ ]);
+ $response = $this->actingAs($jack)->get("api/v4/fs");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame(4, $json['count']);
+ $this->assertSame('test1', $json['list'][0]['name']);
+ $this->assertSame($john->email, $json['list'][0]['owner']);
+ $this->assertSame('test11', $json['list'][1]['name']);
+ $this->assertTrue(empty($json['list'][1]['owner']));
+ $this->assertSame('test2', $json['list'][2]['name']);
+ $this->assertSame($john->email, $json['list'][2]['owner']);
+ $this->assertSame('test22', $json['list'][3]['name']);
+ $this->assertTrue(empty($json['list'][3]['owner']));
+ }
+
+ /**
+ * Test reading a file in a shared collection
+ */
+ public function testFileRead(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestCollection($john, 'test');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+ $folder->children()->attach($file);
+
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}");
+ $response->assertStatus(403);
+
+ $folder->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ | Share::WRITE,
+ ]);
+
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}?downloadUrl=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $this->assertFalse($json['isOwner']);
+ $this->assertTrue($json['canUpdate']);
+ $this->assertTrue($json['canDelete']);
+ }
+
+ /**
+ * Test updating a file in a shared collection
+ */
+ public function testFileUpdate(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestCollection($john, 'test');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+ $folder->children()->attach($file);
+
+ $share = $folder->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ,
+ ]);
+
+ $post = ['name' => 'new name.txt'];
+
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$file->id}", $post);
+ $response->assertStatus(403);
+
+ $share->rights |= Share::WRITE;
+ $share->save();
+
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$file->id}", $post);
+ $response->assertStatus(200);
+
+ $file->refresh();
+ $this->assertSame($post['name'], $file->getProperty('name'));
+
+ $post = ['name' => 'new col'];
+
+ // Update (rename) the shared collection
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$folder->id}", $post);
+ $response->assertStatus(403);
+
+ // Update (rename) a folder in the shared collection
+ $folder2 = $this->getTestCollection($john, 'test2');
+ $folder->children()->attach($folder2);
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$folder2->id}", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame($post['name'], $folder2->getProperty('name'));
+ }
+}
diff --git a/src/tests/Feature/Controller/FsTest.php b/src/tests/Feature/Controller/FsTest.php
--- a/src/tests/Feature/Controller/FsTest.php
+++ b/src/tests/Feature/Controller/FsTest.php
@@ -3,8 +3,6 @@
namespace Tests\Feature\Controller;
use App\Fs\Item;
-use App\User;
-use App\Utils;
use Illuminate\Support\Facades\Cache;
use Tests\TestCaseFs;
@@ -42,8 +40,6 @@
$this->assertNull(Item::find($file->id));
// Note: The file is expected to stay still in the filesystem, we're not testing this here.
-
- // TODO: Test acting as another user with permissions
}
/**
@@ -102,24 +98,6 @@
$this->assertSame('Teśt content', $response->streamedContent());
- // Test acting as another user with read permission
- $permission = $this->getTestFilePermission($file, $jack, 'r');
- $response = $this->actingAs($jack)->get("api/v4/fs/{$permission->key}?downloadUrl=1");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame($file->id, $json['id']);
- $link = $json['downloadUrl'];
-
- // Fetch the file content
- $response = $this->get(substr($link, strpos($link, '/api/') + 1));
- $response->assertStatus(200)
- ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
- ->assertHeader('Content-Type', $file->getProperty('mimetype'));
-
- $this->assertSame('Teśt content', $response->streamedContent());
-
// Test downloading a multi-chunk file
$file = $this->getTestFile($john, 'test2.txt', ['T1', 'T2'], ['mimetype' => 'plain/text']);
$response = $this->actingAs($john)->get("api/v4/fs/{$file->id}?downloadUrl=1");
@@ -139,155 +117,6 @@
$this->assertSame('T1T2', $response->streamedContent());
}
- /**
- * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/fs/<file-id>/permissions)
- */
- public function testPermissions(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $file = $this->getTestFile($john, 'test1.txt', []);
-
- // Unauth access not allowed
- $response = $this->get("api/v4/fs/{$file->id}/permissions");
- $response->assertStatus(401);
- $response = $this->post("api/v4/fs/{$file->id}/permissions", []);
- $response->assertStatus(401);
-
- // Non-existing file
- $response = $this->actingAs($john)->get("api/v4/fs/1234/permissions");
- $response->assertStatus(404);
- $response = $this->actingAs($john)->post("api/v4/fs/1234/permissions", []);
- $response->assertStatus(404);
-
- // No permissions to the file
- $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}/permissions");
- $response->assertStatus(403);
- $response = $this->actingAs($jack)->post("api/v4/fs/{$file->id}/permissions", []);
- $response->assertStatus(403);
-
- // Expect an empty list of permissions
- $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}/permissions");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame([], $json['list']);
- $this->assertSame(0, $json['count']);
-
- // Empty input
- $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", []);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertCount(2, $json['errors']);
- $this->assertSame(["The user field is required."], $json['errors']['user']);
- $this->assertSame(["The permissions field is required."], $json['errors']['permissions']);
-
- // Test more input validation
- $post = ['user' => 'user', 'permissions' => 'read'];
- $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertCount(2, $json['errors']);
- $this->assertSame(["The user must be a valid email address."], $json['errors']['user']);
- $this->assertSame("The file permission is invalid.", $json['errors']['permissions']);
-
- // Let's add some permission
- $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only'];
- $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertSame("File permissions created successfully.", $json['message']);
-
- $permission = $file->properties()->where('key', 'like', 'share-%')->orderBy('value')->first();
-
- $this->assertSame("{$jack->email}:r", $permission->value);
- $this->assertSame($permission->key, $json['id']);
- $this->assertSame($jack->email, $json['user']);
- $this->assertSame('read-only', $json['permissions']);
- $this->assertSame(Utils::serviceUrl('file/' . $permission->key), $json['link']);
-
- // Error handling on use of the same user
- $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only'];
- $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertSame("File permission already exists.", $json['errors']['user']);
-
- // Test update
- $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/1234", $post);
- $response->assertStatus(404);
-
- $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-write'];
- $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/{$permission->key}", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertSame("File permissions updated successfully.", $json['message']);
-
- $permission->refresh();
-
- $this->assertSame("{$jack->email}:rw", $permission->value);
- $this->assertSame($permission->key, $json['id']);
- $this->assertSame($jack->email, $json['user']);
- $this->assertSame('read-write', $json['permissions']);
- $this->assertSame(Utils::serviceUrl('file/' . $permission->key), $json['link']);
-
- // Input validation on update
- $post = ['user' => 'jack@kolab.org', 'permissions' => 'read'];
- $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/{$permission->key}", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertSame('error', $json['status']);
- $this->assertSame("The file permission is invalid.", $json['errors']['permissions']);
-
- // Test GET with existing permissions
- $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}/permissions");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertCount(1, $json['list']);
- $this->assertSame(1, $json['count']);
-
- $this->assertSame($permission->key, $json['list'][0]['id']);
- $this->assertSame($jack->email, $json['list'][0]['user']);
- $this->assertSame('read-write', $json['list'][0]['permissions']);
- $this->assertSame(Utils::serviceUrl('file/' . $permission->key), $json['list'][0]['link']);
-
- // Delete permission
- $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}/permissions/1234");
- $response->assertStatus(404);
-
- $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}/permissions/{$permission->key}");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertSame("File permissions deleted successfully.", $json['message']);
-
- $this->assertCount(0, $file->properties()->where('key', 'like', 'share-%')->get());
- }
-
/**
* Test fetching file/folders list (GET /api/v4/fs)
*/
@@ -376,6 +205,14 @@
$collection = $this->getTestCollection($john, 'My Test Collection');
$collection->children()->attach($file1);
+ // Non-existing parent collection
+ $response = $this->actingAs($john)->get("api/v4/fs?parent=1234");
+ $response->assertStatus(403);
+
+ // Parent is a file
+ $response = $this->actingAs($john)->get("api/v4/fs?parent={$file1->id}");
+ $response->assertStatus(403);
+
// List files in collection
$response = $this->actingAs($john)->get("api/v4/fs?parent={$collection->id}");
$response->assertStatus(200);
@@ -460,18 +297,6 @@
->assertHeader('Content-Type', $file->getProperty('mimetype'));
$this->assertSame('Teśt content', $response->streamedContent());
-
- // Test acting as a user with file permissions
- $permission = $this->getTestFilePermission($file, $jack, 'r');
- $response = $this->actingAs($jack)->get("api/v4/fs/{$permission->key}");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame($file->id, $json['id']);
- $this->assertFalse($json['isOwner']);
- $this->assertFalse($json['canUpdate']);
- $this->assertFalse($json['canDelete']);
}
/**
@@ -642,6 +467,7 @@
*/
public function testStoreRelation(): void
{
+ $jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$collection = $this->getTestCollection($john, 'My Test Collection');
@@ -818,8 +644,28 @@
$this->assertMatchesRegularExpression('|^[a-z]+/[a-z-]+$|', $file->getProperty('mimetype'));
$this->assertSame(strlen($body), (int) $file->getProperty('size'));
- // TODO: Test acting as another user with file permissions
+ // Rename a collection
+ $collection = $this->getTestCollection($john, 'My Test Collection');
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$collection->id}?name=Updated");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Collection updated successfully.", $json['message']);
+ $this->assertSame('Updated', $collection->getProperty('name'));
+
+ // Only media=metadata is allowed on a collection
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$collection->id}?media=content");
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The specified media is invalid.", $json['errors']['media']);
+
// TODO: Test media=resumable
+ $this->markTestIncomplete();
}
/**
@@ -861,5 +707,33 @@
$parents = $file->parents()->get();
$this->assertSame(1, count($parents));
$this->assertSame($collection2->id, $parents->first()->id);
+
+ // Set parents on a collection
+ $headers = ["X-Kolab-Parents" => implode(',', [$collection2->id])];
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$collection1->id}", $headers, '');
+ $response->assertStatus(200);
+ $this->assertSame('success', $response->json()['status']);
+
+ $parents = $collection1->parents()->get();
+ $this->assertSame(1, count($parents));
+ $this->assertSame($collection2->id, $parents[0]->id);
+
+ // Setting parent to self is forbidden
+ $headers = ["X-Kolab-Parents" => implode(',', [$collection1->id])];
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$collection1->id}", $headers, '');
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(['Cannot assign self as a parent.'], $json['errors']);
+
+ // A file cannot be a parent
+ $headers = ["X-Kolab-Parents" => implode(',', [$file->id])];
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$collection1->id}", $headers, '');
+ $response->assertStatus(422);
+
+ $json = $response->json();
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(['Specified parent does not exist.'], $json['errors']);
}
}
diff --git a/src/tests/Feature/DataMigrator/KolabTest.php b/src/tests/Feature/DataMigrator/KolabTest.php
--- a/src/tests/Feature/DataMigrator/KolabTest.php
+++ b/src/tests/Feature/DataMigrator/KolabTest.php
@@ -9,6 +9,7 @@
use App\DataMigrator\Interface\Folder;
use App\DataMigrator\Queue as MigratorQueue;
use App\Fs\Item as FsItem;
+use App\Fs\Share as FsShare;
use App\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage as LaravelStorage;
@@ -185,6 +186,11 @@
$this->assertSame($file_content, Storage::fileFetch($files['test2.odt']));
$this->assertSame('', Storage::fileFetch($files['empty.txt']));
+ $shares = $folders['Files']->sharesList()->all();
+ $this->assertCount(2, $shares);
+ $this->assertSame(FsShare::READ | FsShare::WRITE, $shares['john@kolab.org']->rights);
+ $this->assertSame(FsShare::READ, $shares['joe@kolab.org']->rights);
+
// Assert migrated notes
$this->assertArrayHasKey('Notes', $folders);
$this->assertArrayHasKey('Notes » Sub Notes', $folders);
@@ -428,6 +434,8 @@
throw new \Exception("Failed to set metadata");
}
}
+ $imap->setACL('Files', 'john@kolab.org', 'lrswi');
+ $imap->setACL('Files', 'joe@kolab.org', 'lrs');
$imap->setACL('Calendar', 'john@kolab.org', 'lrswi');
if (!$imap->setMetadata('Calendar', ['/shared/vendor/kolab/color' => 'AABB' . sprintf('%02d', date('d'))])) {
throw new \Exception("Failed to set metadata");
diff --git a/src/tests/Feature/Fs/ItemTest.php b/src/tests/Feature/Fs/ItemTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Fs/ItemTest.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Tests\Feature\Fs;
+
+use App\Fs\Item;
+use App\Fs\Share;
+use Tests\TestCaseFs;
+
+class ItemTest extends TestCaseFs
+{
+ /**
+ * Test Item::isReadable()
+ */
+ public function testIsReadable(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+ $folder1 = $this->getTestCollection($john, 'test1');
+ $folder2 = $this->getTestCollection($john, 'test2');
+
+ // No parents yet
+ $this->assertTrue($file->isReadable($john));
+ $this->assertTrue($folder1->isReadable($john));
+ $this->assertFalse($file->isReadable($jack));
+ $this->assertFalse($folder1->isReadable($jack));
+
+ // Create /folder1/folder2/file hierarchy
+ $folder1->children()->attach($folder2);
+ $folder2->children()->attach($file);
+
+ $share = $folder1->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ,
+ ]);
+
+ $this->assertTrue($file->isReadable($john));
+ $this->assertTrue($folder1->isReadable($john));
+ $this->assertTrue($folder2->isReadable($john));
+ $this->assertTrue($file->isReadable($jack));
+ $this->assertTrue($folder1->isReadable($jack));
+ $this->assertTrue($folder2->isReadable($john));
+
+ $share->item_id = $folder2->id;
+ $share->save();
+ $this->assertTrue($file->isReadable($john));
+ $this->assertTrue($folder1->isReadable($john));
+ $this->assertTrue($folder2->isReadable($john));
+ $this->assertTrue($file->isReadable($jack));
+ $this->assertFalse($folder1->isReadable($jack));
+ $this->assertTrue($folder2->isReadable($jack));
+ }
+
+ /**
+ * Test Item::isEditable()
+ */
+ public function testIsEditable(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+ $folder1 = $this->getTestCollection($john, 'test1');
+ $folder2 = $this->getTestCollection($john, 'test2');
+
+ // No parents yet
+ $this->assertTrue($file->isEditable($john));
+ $this->assertTrue($folder1->isEditable($john));
+ $this->assertFalse($file->isEditable($jack));
+ $this->assertFalse($folder1->isEditable($jack));
+
+ // Create /folder1/folder2/file hierarchy
+ $folder1->children()->attach($folder2);
+ $folder2->children()->attach($file);
+
+ $share = $folder1->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ | Share::WRITE,
+ ]);
+
+ $this->assertTrue($file->isEditable($john));
+ $this->assertTrue($folder1->isEditable($john));
+ $this->assertTrue($folder2->isEditable($john));
+ $this->assertTrue($file->isEditable($jack));
+ $this->assertFalse($folder1->isEditable($jack));
+ $this->assertTrue($folder2->isEditable($jack));
+
+ $share->item_id = $folder2->id;
+ $share->save();
+ $this->assertTrue($file->isEditable($john));
+ $this->assertTrue($folder1->isEditable($john));
+ $this->assertTrue($folder2->isEditable($john));
+ $this->assertTrue($file->isEditable($jack));
+ $this->assertFalse($folder1->isEditable($jack));
+ $this->assertFalse($folder2->isEditable($jack));
+ }
+
+ /**
+ * Test Item::isWritable()
+ */
+ public function testIsWritable(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test.txt', 'Test');
+ $folder1 = $this->getTestCollection($john, 'test1');
+ $folder2 = $this->getTestCollection($john, 'test2');
+
+ // No parents yet
+ $this->assertFalse($file->isWritable($john));
+ $this->assertTrue($folder1->isWritable($john));
+ $this->assertTrue($folder2->isWritable($john));
+ $this->assertFalse($file->isWritable($jack));
+ $this->assertFalse($folder1->isWritable($jack));
+ $this->assertFalse($folder2->isWritable($jack));
+
+ // Create /folder1/folder2/file hierarchy
+ $folder1->children()->attach($folder2);
+ $folder2->children()->attach($file);
+
+ $share = $folder1->shares()->create([
+ 'sharee_id' => $jack->id,
+ 'sharee_type' => $jack::class,
+ 'rights' => Share::READ,
+ ]);
+
+ $this->assertFalse($file->isWritable($john));
+ $this->assertTrue($folder1->isWritable($john));
+ $this->assertTrue($folder2->isWritable($john));
+ $this->assertFalse($file->isWritable($jack));
+ $this->assertFalse($folder1->isWritable($jack));
+ $this->assertFalse($folder2->isWritable($jack));
+
+ $share->rights |= Share::WRITE;
+ $share->save();
+ $this->assertFalse($file->isWritable($john));
+ $this->assertTrue($folder1->isWritable($john));
+ $this->assertTrue($folder2->isWritable($john));
+ $this->assertFalse($file->isWritable($jack));
+ $this->assertTrue($folder1->isWritable($jack));
+ $this->assertTrue($folder2->isWritable($jack));
+
+ $share->item_id = $folder2->id;
+ $share->save();
+ $this->assertFalse($file->isWritable($john));
+ $this->assertTrue($folder1->isWritable($john));
+ $this->assertTrue($folder2->isWritable($john));
+ $this->assertFalse($file->isWritable($jack));
+ $this->assertFalse($folder1->isWritable($jack));
+ $this->assertTrue($folder2->isWritable($jack));
+ }
+}
diff --git a/src/tests/TestCaseFs.php b/src/tests/TestCaseFs.php
--- a/src/tests/TestCaseFs.php
+++ b/src/tests/TestCaseFs.php
@@ -148,7 +148,7 @@
*
* @return TestResponse HTTP Response object
*/
- protected function sendRawBody(?User $user, string $method, string $uri, array $headers, string $content)
+ protected function sendRawBody(?User $user, string $method, string $uri, array $headers = [], string $content = '')
{
$headers['Content-Length'] = strlen($content);
diff --git a/src/tests/Unit/Rules/FileNameTest.php b/src/tests/Unit/Rules/FileNameTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/FileNameTest.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\FileName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class FileNameTest extends TestCase
+{
+ /**
+ * Test filename validation
+ *
+ * @dataProvider provideFilenameCases
+ */
+ public function testFilename($filename, $expected_result): void
+ {
+ // Instead of doing direct tests, we use validator to make sure
+ // it works with the framework api
+ $v = Validator::make(
+ ['name' => $filename],
+ ['name' => [new FileName()]]
+ );
+
+ $result = null;
+ if ($v->fails()) {
+ $result = $v->errors()->toArray()['name'][0];
+ }
+
+ $this->assertSame($expected_result, $result);
+ }
+
+ /**
+ * List of filename validation cases for testFilename()
+ *
+ * @return array Arguments for testFilename()
+ */
+ public static function provideFilenameCases(): iterable
+ {
+ $err = 'The file name is invalid.';
+ return [
+ // invalid
+ ['.', $err],
+ ['..', $err],
+ ['a*a', $err],
+ ["a\na", $err],
+ [' test', $err],
+ ['test ', $err],
+ [str_repeat('a', 513), 'The name may not be longer than 512 characters.'],
+ // valid
+ ['image.png', null],
+ ['image', null],
+ [str_repeat('a', 512), null],
+ ];
+ }
+}
diff --git a/src/tests/Unit/Rules/ResourceNameTest.php b/src/tests/Unit/Rules/ResourceNameTest.php
--- a/src/tests/Unit/Rules/ResourceNameTest.php
+++ b/src/tests/Unit/Rules/ResourceNameTest.php
@@ -29,7 +29,7 @@
// Length limit
$v = Validator::make(['name' => str_repeat('a', 192)], $rules);
- $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+ $this->assertSame(['name' => ["The name may not be longer than 191 characters."]], $v->errors()->toArray());
// Existing resource
$v = Validator::make(['name' => 'Conference Room #1'], $rules);
diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php
--- a/src/tests/Unit/Rules/SharedFolderNameTest.php
+++ b/src/tests/Unit/Rules/SharedFolderNameTest.php
@@ -35,7 +35,7 @@
// Length limit
$v = Validator::make(['name' => str_repeat('a', 192)], $rules);
- $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+ $this->assertSame(['name' => ["The name may not be longer than 191 characters."]], $v->errors()->toArray());
// Existing resource
$v = Validator::make(['name' => 'Library'], $rules);
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 1:06 AM (21 h, 29 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18821827
Default Alt Text
D5841.1775178394.diff (116 KB)
Attached To
Mode
D5841: Files: Sharing
Attached
Detach File
Event Timeline