Page MenuHomePhorge

D5841.1775187711.diff
No OneTemporary

Authored By
Unknown
Size
110 KB
Referenced Files
None
Subscribers
None

D5841.1775187711.diff

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.
@@ -101,6 +104,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.
*/
@@ -187,6 +217,69 @@
return (bool) ($this->type & self::TYPE_INCOMPLETE);
}
+ /**
+ * 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
*
@@ -305,4 +398,34 @@
{
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');
+ }
}
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,13 +301,14 @@
}
/**
- * 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);
@@ -461,23 +317,14 @@
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 metadata input
+ $v = Validator::make($request->all(), $rules = ['name' => [new FileName()]]);
- // Validate file name input
- if ($filename != $file->getProperty('name')) {
- $v = Validator::make($request->all(), ['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 +337,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 +356,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 +388,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 +401,8 @@
* @param string $id Upload (not file) identifier
*
* @return JsonResponse The response
+ *
+ * @unauthenticated
*/
public function upload(Request $request, $id)
{
@@ -597,7 +449,7 @@
}
$parents = $this->getInputParents($request);
- if ($errorResponse = $this->validateParents($parents)) {
+ if ($errorResponse = $this->validateParents($parents, null, $owner)) {
return $errorResponse;
}
@@ -621,7 +473,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 +492,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 +517,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 +540,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,8 +553,17 @@
return 404;
}
- if (!$isShare && $user->id != $file->user_id) {
- return 403;
+ 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,
+ };
+
+ if (!$has_access) {
+ return 403;
+ }
}
return $file;
@@ -786,16 +603,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,206 @@
+<?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
+ {
+ $this->markTestIncomplete();
+ }
+}
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,352 @@
+<?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'));
+
+ // TODO: Update (rename) a collection
+ }
+}
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/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

Mime Type
text/plain
Expires
Fri, Apr 3, 3:41 AM (16 h, 41 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822456
Default Alt Text
D5841.1775187711.diff (110 KB)

Event Timeline