Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117752024
D5841.1775187709.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
96 KB
Referenced Files
None
Subscribers
None
D5841.1775187709.diff
View Options
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
@@ -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)) {
@@ -461,11 +316,6 @@
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') {
@@ -490,7 +340,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;
@@ -514,7 +364,7 @@
}
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,
+ 'shareee_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,
+ 'shareee_type' => User::class,
+ ])->delete();
}
/**
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
@@ -52,6 +52,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",
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
@@ -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/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> ..
@@ -35,14 +35,16 @@
<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 }}
+ <folder-icon v-if="file.type === 'collection'" class="me-1" :folder="file"></folder-icon>
+ <svg-icon v-else :icon="fileIcon(file)" class="me-1" style="width:1em"></svg-icon>
+ {{ file.name }} <span v-if="file.owner && file.type === 'collection'">({{ file.owner }})</span>
</router-link>
</td>
<td class="size">
<span>{{ fileSize(file) }}</span>
</td>
<td class="buttons">
+ <btn v-if="file.type == 'collection' && !file.owner" class="button-shares p-0 ms-1" @click="shareCollection(file)" icon="share-nodes" :title="$t('file.sharing')"></btn>
<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>
</td>
@@ -64,11 +66,21 @@
</div>
</div>
</modal-dialog>
+ <modal-dialog id="shares-dialog" ref="sharesDialog" :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>
</div>
</template>
<script>
+ 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'
@@ -83,6 +95,7 @@
require('@fortawesome/free-regular-svg-icons/faFileVideo').definition,
require('@fortawesome/free-solid-svg-icons/faFolder').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
+ require('@fortawesome/free-solid-svg-icons/faShareNodes').definition,
require('@fortawesome/free-solid-svg-icons/faUpload').definition,
)
@@ -96,6 +109,8 @@
export default {
components: {
+ AclInput,
+ FolderIcon,
ModalDialog
},
mixins: [ ListTools ],
@@ -214,6 +229,33 @@
searchFiles(search) {
this.loadFiles({ reset: true, search })
},
+ shareCollection(folder) {
+ this.form = { itemId: folder.id, shares: [] }
+ this.$root.clearFormValidation($('#share-dialog'))
+ this.$refs.sharesDialog.show()
+
+ this.$nextTick().then(() => {
+ const loader = ['#shares-dialog .modal-body', { 'min-height': '20em', small: false }]
+
+ axios.get('/api/v4/fs/' + folder.id + '/shares', { loader })
+ .then(response => {
+ this.form.shares = response.data.list
+ })
+ })
+ },
+ 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.sharesDialog.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);
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 3:41 AM (14 h, 17 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822454
Default Alt Text
D5841.1775187709.diff (96 KB)
Attached To
Mode
D5841: Files: Sharing
Attached
Detach File
Event Timeline