diff --git a/src/app/Http/Controllers/API/V4/FsController.php b/src/app/Http/Controllers/API/V4/FsController.php index 3dcf03fd..3cd5b3df 100644 --- a/src/app/Http/Controllers/API/V4/FsController.php +++ b/src/app/Http/Controllers/API/V4/FsController.php @@ -1,854 +1,857 @@ inputItem($id, null); if (is_int($file)) { return $this->errorResponse($file); } // Here we're just marking the file as deleted, it will be removed from the // storage later with the fs:expunge command $file->delete(); if ($file->type & Item::TYPE_COLLECTION) { $message = self::trans('app.collection-delete-success'); } return response()->json([ 'status' => 'success', 'message' => $message ?? self::trans('app.file-delete-success'), ]); } /** * Fetch content of a file. * * @param string $id The download (not file) identifier. * * @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse */ public function download($id) { $fileId = Cache::get('download:' . $id); if (!$fileId) { return response('Not found', 404); } $file = Item::find($fileId); if (!$file) { return response('Not found', 404); } return Storage::fileDownload($file); } /** * Fetch the permissions for the specific file. * * @param string $fileId The file identifier. * * @return \Illuminate\Http\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()->where('key', 'like', 'share-%')->get()->map( 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 \Illuminate\Http\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()->where('key', 'like', 'share-%')->where('value', 'like', "$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-' . \App\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 \Illuminate\Http\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 \Illuminate\Http\Request $request The API request. * @param string $fileId The file identifier. * @param string $id The file permission identifier. * * @return \Illuminate\Http\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']) && strpos($property->value, "$user:") !== 0) { if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$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). * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $parent = request()->input('parent'); $type = request()->input('type'); $pageSize = 100; $hasMore = false; $user = $this->guard()->user(); $result = $user->fsItems()->select('fs_items.*', 'fs_properties.value as name'); if ($parent) { $result->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id') ->where('fs_relations.item_id', $parent); } else { $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id') ->whereNull('fs_relations.related_id'); } // Add properties $result->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') ->whereNot('type', '&', Item::TYPE_INCOMPLETE) ->where('key', 'name'); if ($type) { if ($type == self::TYPE_COLLECTION) { $result->where('type', '&', Item::TYPE_COLLECTION); } else { $result->where('type', '&', Item::TYPE_FILE); } } if (strlen($search)) { $result->whereLike('fs_properties.value', $search); } $result = $result->orderBy('name') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($file) { $result = $this->objectToClient($file); $result['name'] = $file->name; // @phpstan-ignore-line return $result; } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Fetch the specific file metadata or content. * * @param string $id The file identifier. * * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\StreamedResponse */ public function show($id) { $file = $this->inputItem($id, self::READ); if (is_int($file)) { return $this->errorResponse($file); } $response = $this->objectToClient($file, true); if (request()->input('downloadUrl')) { // Generate a download URL (that does not require authentication) $downloadId = Utils::uuidStr(); Cache::add('download:' . $downloadId, $file->id, 60); $response['downloadUrl'] = Utils::serviceUrl('api/v4/fs/downloads/' . $downloadId); } elseif (request()->input('download')) { // Return the file content return Storage::fileDownload($file); } $response['mtime'] = $file->updated_at->format('Y-m-d H:i'); // TODO: Handle read-write/full access rights $isOwner = $this->guard()->user()->id == $file->user_id; $response['canUpdate'] = $isOwner; $response['canDelete'] = $isOwner; $response['isOwner'] = $isOwner; return response()->json($response); } /** * Create a new file. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $type = $request->input('type'); if ($type == self::TYPE_COLLECTION) { return $this->createCollection($request); } // Validate file name input $v = Validator::make($request->all(), ['name' => ['required', new FileName()]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $parents = $this->getInputParents($request); if ($errorResponse = $this->validateParents($parents)) { return $errorResponse; } $filename = $request->input('name'); $media = $request->input('media'); // TODO: Delete the existing incomplete file with the same name? $properties = ['name' => $filename]; foreach ($request->all() as $key => $value) { if (str_starts_with($key, 'property-')) { $propertyKey = substr($key, 9); if ($errorResponse = $this->validatePropertyName($propertyKey)) { return $errorResponse; } $properties[$propertyKey] = $value; } } DB::beginTransaction(); $file = $this->deduplicateOrCreate($request, Item::TYPE_INCOMPLETE | Item::TYPE_FILE); $file->setProperties($properties); if (!empty($parents)) { $file->parents()->sync($parents); } DB::commit(); $params = []; $params['mimetype'] = $request->headers->get('Content-Type', null); if ($media == 'resumable') { $params['uploadId'] = 'resumable'; $params['size'] = $request->input('size'); $params['from'] = $request->input('from') ?: 0; } try { $response = Storage::fileInput($request->getContent(true), $params, $file); $response['status'] = 'success'; if (!empty($response['id'])) { $response += $this->objectToClient($file, true); $response['message'] = self::trans('app.file-create-success'); } } catch (\Exception $e) { \Log::error($e); $file->delete(); return $this->errorResponse(500); } return response()->json($response); } /** * Update a file. * * @param \Illuminate\Http\Request $request The API request. * @param string $id File identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $file = $this->inputItem($id, self::WRITE); if (is_int($file)) { return $this->errorResponse($file); } if ($file->type == Item::TYPE_COLLECTION) { // Updating a collection is not supported yet return $this->errorResponse(405); } $media = $request->input('media') ?: 'metadata'; if ($media == 'metadata') { $filename = $request->input('name'); // Validate file name input if ($filename != $file->getProperty('name')) { $v = Validator::make($request->all(), ['name' => [new FileName()]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } } $parents = [ 'X-Kolab-Parents' => [], 'X-Kolab-Add-Parents' => [], 'X-Kolab-Remove-Parents' => [], ]; // Collect and validate parents from the request headers foreach (array_keys($parents) as $header) { if ($value = $request->headers->get($header, null)) { $list = explode(',', $value); if ($errorResponse = $this->validateParents($list)) { return $errorResponse; } $parents[$header] = $list; } } DB::beginTransaction(); if (count($parents['X-Kolab-Parents'])) { $file->parents()->sync($parents['X-Kolab-Parents']); } if (count($parents['X-Kolab-Add-Parents'])) { $file->parents()->syncWithoutDetaching($parents['X-Kolab-Add-Parents']); } if (count($parents['X-Kolab-Remove-Parents'])) { $file->parents()->detach($parents['X-Kolab-Remove-Parents']); } if ($filename != $file->getProperty('name')) { $file->setProperty('name', $filename); } DB::commit(); } elseif ($media == 'resumable' || $media == 'content') { $params = []; if ($media == 'resumable') { $params['uploadId'] = 'resumable'; $params['size'] = $request->input('size'); $params['from'] = $request->input('from') ?: 0; } try { $response = Storage::fileInput($request->getContent(true), $params, $file); } catch (\Exception $e) { \Log::error($e); return $this->errorResponse(500); } } else { $errors = ['media' => self::trans('validation.entryinvalid', ['attribute' => 'media'])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $response['status'] = 'success'; if ($media == 'metadata' || !empty($response['id'])) { $response += $this->objectToClient($file, true); $response['message'] = self::trans('app.file-update-success'); } return response()->json($response); } /** * Upload a file content. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Upload (not file) identifier * * @return \Illuminate\Http\JsonResponse The response */ public function upload(Request $request, $id) { $params = [ 'uploadId' => $id, 'from' => $request->input('from') ?: 0, ]; try { $response = Storage::fileInput($request->getContent(true), $params); $response['status'] = 'success'; if (!empty($response['id'])) { $response += $this->objectToClient(Item::find($response['id']), true); $response['message'] = self::trans('app.file-upload-success'); } } catch (\Exception $e) { \Log::error($e); return $this->errorResponse(500); } return response()->json($response); } /** * Create a new collection. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ protected function createCollection(Request $request) { // Validate file name input $v = Validator::make($request->all(), [ 'name' => ['required', new FileName()], 'deviceId' => ['max:255'], 'collectionType' => ['max:255'], ]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $parents = $this->getInputParents($request); if ($errorResponse = $this->validateParents($parents)) { return $errorResponse; } $properties = [ 'name' => $request->input('name'), 'deviceId' => $request->input('deviceId'), 'collectionType' => $request->input('collectionType'), ]; foreach ($request->all() as $key => $value) { if (str_starts_with($key, 'property-')) { $propertyKey = substr($key, 9); if ($errorResponse = $this->validatePropertyName($propertyKey)) { return $errorResponse; } $properties[$propertyKey] = $value; } } DB::beginTransaction(); $item = $this->deduplicateOrCreate($request, Item::TYPE_COLLECTION); $item->setProperties($properties); if (!empty($parents)) { $item->parents()->sync($parents); } DB::commit(); return response()->json([ 'status' => 'success', 'id' => $item->id, 'message' => self::trans('app.collection-create-success'), ]); } /** * Find or create an item, using deduplicate parameters */ protected function deduplicateOrCreate(Request $request, $type): Item { $user = $this->guard()->user(); $item = null; if ($request->has('deduplicate-property')) { // query for item by deduplicate-value $item = $user->fsItems()->select('fs_items.*') ->join('fs_properties', function ($join) use ($request) { $join->on('fs_items.id', '=', 'fs_properties.item_id') ->where('fs_properties.key', $request->input('deduplicate-property')); }) ->where('type', '&', $type) ->whereLike('fs_properties.value', $request->input('deduplicate-value')) ->first(); // FIXME: Should we throw an error if there's more than one item? } if (!$item) { $item = $user->fsItems()->create(['type' => $type]); } 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 { list($user, $acl) = explode(':', $value); $perms = strpos($acl, self::WRITE) !== false ? '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 * * @return \App\Fs\Item|int File object or error code */ protected function inputItem($fileId, $permission) { $user = $this->guard()->user(); $isShare = str_starts_with($fileId, 'share-'); // Access via file permission identifier if ($isShare) { $property = Property::where('key', $fileId)->first(); if (!$property) { return 404; } list($acl_user, $acl) = explode(':', $property->value); if (!$permission || $acl_user != $user->email || strpos($acl, $permission) === false) { return 403; } $fileId = $property->item_id; } $file = Item::find($fileId); if (!$file) { return 404; } if (!$isShare && $user->id != $file->user_id) { return 403; } if ($file->type & Item::TYPE_FILE && $file->type & Item::TYPE_INCOMPLETE) { return 404; } return $file; } /** * Prepare a file object for the UI. * * @param object $object An object * @param bool $full Include all object properties * * @return array Object information */ protected function objectToClient($object, bool $full = false): array { $result = ['id' => $object->id]; if ($object->type & Item::TYPE_COLLECTION) { $result['type'] = self::TYPE_COLLECTION; } elseif ($object->type & Item::TYPE_FILE) { $result['type'] = self::TYPE_FILE; } else { $result['type'] = self::TYPE_UNKNOWN; } if ($full) { $props = array_filter($object->getProperties(['name', 'size', 'mimetype'])); // convert size to int and make sure the property exists $props['size'] = (int) ($props['size'] ?? 0); $result += $props; } + $result['updated_at'] = $object->updated_at->toDateTimeString(); + $result['created_at'] = $object->created_at->toDateTimeString(); + return $result; } /** * Validate parents list */ protected function validateParents($parents) { $user = $this->guard()->user(); if (!empty($parents) && count($parents) != $user->fsItems()->whereIn('id', $parents)->count()) { $error = self::trans('validation.fsparentunknown'); return response()->json(['status' => 'error', 'errors' => [$error]], 422); } return null; } /** * Collect collection Ids from input */ protected function getInputParents(Request $request): array { $parents = []; if ($parentHeader = $request->headers->get('X-Kolab-Parents')) { $parents = explode(',', $parentHeader); } if ($parent = $request->input('parent')) { $parents = array_merge($parents, [$parent]); } return array_values(array_unique($parents)); } /** * Validate property name */ protected function validatePropertyName(string $name) { if (strlen($name) > 191) { $error = self::trans('validation.max.string', ['attribute' => $name, 'max' => 191]); return response()->json(['status' => 'error', 'errors' => [$error]], 422); } if (preg_match('/^(name)$/i', $name)) { $error = self::trans('validation.prohibited', ['attribute' => $name]); return response()->json(['status' => 'error', 'errors' => [$error]], 422); } if (!preg_match('/^[a-zA-Z0-9_-]+$/', $name)) { $error = self::trans('validation.regex_format', ['attribute' => $name, 'format' => 'a-zA-Z0-9_-']); return response()->json(['status' => 'error', 'errors' => [$error]], 422); } return null; } } diff --git a/src/tests/Feature/Controller/FsTest.php b/src/tests/Feature/Controller/FsTest.php index 757d212d..51061f74 100644 --- a/src/tests/Feature/Controller/FsTest.php +++ b/src/tests/Feature/Controller/FsTest.php @@ -1,1008 +1,1010 @@ delete(); } /** * {@inheritDoc} */ public function tearDown(): void { Item::query()->delete(); $disk = LaravelStorage::disk(\config('filesystems.default')); foreach ($disk->listContents('') as $dir) { $disk->deleteDirectory($dir->path()); } parent::tearDown(); } /** * Test deleting items (DELETE /api/v4/fs/) */ public function testDelete(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']); // Unauth access $response = $this->delete("api/v4/fs/{$file->id}"); $response->assertStatus(401); // Unauth access $response = $this->actingAs($jack)->delete("api/v4/fs/{$file->id}"); $response->assertStatus(403); // Non-existing file $response = $this->actingAs($john)->delete("api/v4/fs/123"); $response->assertStatus(404); // File owner access $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("File deleted successfully.", $json['message']); $this->assertSame(null, 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 } /** * Test deleting collections (DELETE /api/v4/fs/) */ public function testDeleteCollection(): void { $john = $this->getTestUser('john@kolab.org'); $collection = $this->getTestCollection($john, 'Teśt content'); // File owner access $response = $this->actingAs($john)->delete("api/v4/fs/{$collection->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Collection deleted successfully.", $json['message']); $this->assertSame(null, Item::find($collection->id)); } /** * Test file downloads (GET /api/v4/fs/downloads/) */ public function testDownload(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']); // Unauth access $response = $this->get("api/v4/fs/{$file->id}?downloadUrl=1"); $response->assertStatus(401); $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}?downloadUrl=1"); $response->assertStatus(403); // Non-existing file $response = $this->actingAs($john)->get("api/v4/fs/123456?downloadUrl=1"); $response->assertStatus(404); // Get downloadLink for the file $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}?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 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"); $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=test2.txt") ->assertHeader('Content-Type', $file->getProperty('mimetype')); $this->assertSame('T1T2', $response->streamedContent()); } /** * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/fs//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(\App\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(\App\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(\App\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) */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/fs"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); // Expect an empty list $response = $this->actingAs($user)->get("api/v4/fs"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame([], $json['list']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some files and test again $file1 = $this->getTestFile($user, 'test1.txt', [], ['mimetype' => 'text/plain', 'size' => 12345]); $file2 = $this->getTestFile($user, 'test2.gif', [], ['mimetype' => 'image/gif', 'size' => 10000]); $response = $this->actingAs($user)->get("api/v4/fs"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame('test1.txt', $json['list'][0]['name']); $this->assertSame($file1->id, $json['list'][0]['id']); $this->assertSame('test2.gif', $json['list'][1]['name']); $this->assertSame($file2->id, $json['list'][1]['id']); // Searching $response = $this->actingAs($user)->get("api/v4/fs?search=t2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(1, $json['list']); $this->assertSame('test2.gif', $json['list'][0]['name']); $this->assertSame($file2->id, $json['list'][0]['id']); // TODO: Test paging // Make sure incomplete files are skipped $file1->type |= Item::TYPE_INCOMPLETE; $file1->save(); $response = $this->actingAs($user)->get("api/v4/fs"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame(1, $json['count']); } /** * Test fetching file/folders list (GET /api/v4/fs) */ public function testIndexChildren(): void { $john = $this->getTestUser('john@kolab.org'); $file1 = $this->getTestFile($john, 'test1.txt', 'Teśt content1', ['mimetype' => 'plain/text']); $file2 = $this->getTestFile($john, 'test2.txt', 'Teśt content2', ['mimetype' => 'plain/text']); $collection = $this->getTestCollection($john, 'My Test Collection'); $collection->children()->attach($file1); // List files in collection $response = $this->actingAs($john)->get("api/v4/fs?parent={$collection->id}"); $response->assertStatus(200); $json = $response->json(); $list = $json['list']; $this->assertSame(1, count($list)); $this->assertSame($file1->id, $list[0]['id']); // List files not in a collection $response = $this->actingAs($john)->get("api/v4/fs?type=file"); $response->assertStatus(200); $json = $response->json(); $list = $json['list']; $this->assertSame(1, count($list)); $this->assertSame($file2->id, $list[0]['id']); // Remove from collection $collection->children()->detach($file1); $response = $this->actingAs($john)->get("api/v4/fs?parent={$collection->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, count($response->json()['list'])); } /** * Test fetching file metadata (GET /api/v4/fs/) */ public function testShow(): void { // Unauth access not allowed $response = $this->get("api/v4/fs/1234"); $response->assertStatus(401); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']); // Non-existing file $response = $this->actingAs($jack)->get("api/v4/fs/1234"); $response->assertStatus(404); // Unauthorized access $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}"); $response->assertStatus(403); // Get file metadata $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($file->id, $json['id']); + $this->assertSame($file->updated_at->toDateTimeString(), $json['updated_at']); + $this->assertSame($file->created_at->toDateTimeString(), $json['created_at']); $this->assertSame($file->getProperty('mimetype'), $json['mimetype']); $this->assertSame((int) $file->getProperty('size'), $json['size']); $this->assertSame($file->getProperty('name'), $json['name']); $this->assertSame(true, $json['isOwner']); $this->assertSame(true, $json['canUpdate']); $this->assertSame(true, $json['canDelete']); // Get file content $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}?download=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 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->assertSame(false, $json['isOwner']); $this->assertSame(false, $json['canUpdate']); $this->assertSame(false, $json['canDelete']); } /** * Test creating files (POST /api/v4/fs) */ public function testStore(): void { // Unauth access not allowed $response = $this->post("api/v4/fs"); $response->assertStatus(401); $john = $this->getTestUser('john@kolab.org'); // Test input validation $response = $this->sendRawBody($john, 'POST', "api/v4/fs", [], ''); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame(["The name field is required."], $json['errors']['name']); $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=*.txt", [], ''); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame(["The file name is invalid."], $json['errors']['name']); $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt&parent=unknown", [], ''); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame(["Specified parent does not exist."], $json['errors']); $parent = $this->getTestCollection($john, 'Parent'); // Create a file - the simple method $body = "test content"; $headers = []; $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt&parent={$parent->id}", $headers, $body); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("File created successfully.", $json['message']); $this->assertMatchesRegularExpression('|^[a-z]+/[a-z-]+$|', $json['mimetype']); $this->assertSame(strlen($body), $json['size']); $this->assertSame('test.txt', $json['name']); $file = Item::find($json['id']); $this->assertSame(Item::TYPE_FILE, $file->type); $this->assertSame($json['mimetype'], $file->getProperty('mimetype')); $this->assertSame($json['size'], (int) $file->getProperty('size')); $this->assertSame($json['name'], $file->getProperty('name')); $this->assertSame($body, $this->getTestFileContent($file)); $this->assertSame(1, $file->parents()->count()); $this->assertSame($parent->id, $file->parents()->first()->id); // TODO: Test X-Kolab-Parents } /** * Test creating collections (POST /api/v4/fs?type=collection) */ public function testStoreCollection(): void { $john = $this->getTestUser('john@kolab.org'); $params = [ 'name' => "MyTestCollection", 'deviceId' => "myDeviceId", ]; // Invalid parent $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params + ['parent' => 'unknown']); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame(["Specified parent does not exist."], $json['errors']); // Valid input $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $collection = Item::find($json['id']); $this->assertSame(Item::TYPE_COLLECTION, $collection->type); $this->assertSame($params['name'], $collection->getProperty('name')); $this->assertSame($params['deviceId'], $collection->getProperty('deviceId')); } /** * Test creating collections (POST /api/v4/fs?type=collection) */ public function testStoreCollectionMetadata(): void { $john = $this->getTestUser('john@kolab.org'); $params = [ 'name' => "MyTestCollection", 'deviceId' => "myDeviceId", 'collectionType' => "photoalbum", 'deduplicate-property' => "localId", 'deduplicate-value' => "myDeviceId:localId", 'property-localId' => "myDeviceId:localId", ]; $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $collection = Item::find($json['id']); $this->assertSame(Item::TYPE_COLLECTION, $collection->type); $this->assertSame($params['name'], $collection->getProperty('name')); $this->assertSame($params['deviceId'], $collection->getProperty('deviceId')); $this->assertSame($params['collectionType'], $collection->getProperty('collectionType')); $this->assertSame($params['property-localId'], $collection->getProperty('localId')); // Deduplicate but update the name and parent $parent = $this->getTestCollection($john, 'Parent'); $params = [ 'name' => "MyTestCollection2", 'deviceId' => "myDeviceId", 'parent' => $parent->id, 'collectionType' => "photoalbum", 'deduplicate-property' => "localId", 'deduplicate-value' => "myDeviceId:localId", ]; $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame($collection->id, $json['id']); $this->assertSame($params['name'], $collection->getProperty('name')); // Deduplicate again, but without changes $parent = $this->getTestCollection($john, 'Parent'); $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); } /** * Test store item relations (POST /api/v4/fs) */ public function testStoreRelation(): void { $john = $this->getTestUser('john@kolab.org'); $collection = $this->getTestCollection($john, 'My Test Collection'); $body = "test content"; $headers = ["X-Kolab-Parents" => implode(',', [$collection->id])]; $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt", $headers, $body); $response->assertStatus(200); $json = $response->json(); $newItem = Item::find($json['id']); $this->assertNotNull($newItem); $this->assertSame(1, $newItem->parents()->count()); $this->assertSame($collection->id, $newItem->parents()->first()->id); $collection2 = $this->getTestCollection($john, 'My Test Collection2'); $headers = ["X-Kolab-Parents" => implode(',', [$collection->id, $collection2->id])]; $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test2.txt", $headers, $body); $response->assertStatus(200); $json = $response->json(); $newItem = Item::find($json['id']); $this->assertNotNull($newItem); $this->assertSame(2, $newItem->parents()->count()); } /** * Test store item relations (POST /api/v4/fs) */ public function testStoreRelationParameter(): void { $john = $this->getTestUser('john@kolab.org'); $collection = $this->getTestCollection($john, 'My Test Collection'); $body = "test content"; $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt&parent={$collection->id}", [], $body); $response->assertStatus(200); $json = $response->json(); $newItem = Item::find($json['id']); $this->assertNotNull($newItem); $this->assertSame(1, $newItem->parents()->count()); $this->assertSame($collection->id, $newItem->parents()->first()->id); } /** * Test creating files - resumable (POST /api/v4/fs) */ public function testStoreResumable(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->actingAs($john)->post("api/v4/fs?name=test2.txt&media=resumable&size=400"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['uploaded']); $this->assertMatchesRegularExpression( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $json['uploadId'] ); $upload = Cache::get('upload:' . $json['uploadId']); $file = Item::find($upload['fileId']); $this->assertSame(Item::TYPE_INCOMPLETE | Item::TYPE_FILE, $file->type); $uploadId = $json['uploadId']; $size = 0; $fileContent = ''; for ($x = 0; $x <= 2; $x++) { $body = str_repeat("$x", 100); $response = $this->sendRawBody(null, 'POST', "api/v4/fs/uploads/{$uploadId}?from={$size}", [], $body); $response->assertStatus(200); $json = $response->json(); $size += 100; $fileContent .= $body; $this->assertSame($size, $json['uploaded']); $this->assertSame($uploadId, $json['uploadId']); } $body = str_repeat("$x", 100); $response = $this->sendRawBody(null, 'POST', "api/v4/fs/uploads/{$uploadId}?from={$size}", [], $body); $response->assertStatus(200); $json = $response->json(); $size += 100; $fileContent .= $body; $this->assertSame('success', $json['status']); // $this->assertSame("", $json['message']); $this->assertMatchesRegularExpression('|^[a-z]+/[a-z-]+$|', $json['mimetype']); $this->assertSame($size, $json['size']); $this->assertSame('test2.txt', $json['name']); $file = Item::find($json['id']); $this->assertSame(Item::TYPE_FILE, $file->type); $this->assertSame($json['mimetype'], $file->getProperty('mimetype')); $this->assertSame($json['size'], (int) $file->getProperty('size')); $this->assertSame($json['name'], $file->getProperty('name')); $this->assertSame($fileContent, $this->getTestFileContent($file)); } /** * Test updating files (PUT /api/v4/fs/) */ public function testUpdate(): void { // Unauth access not allowed $response = $this->put("api/v4/fs/1234"); $response->assertStatus(401); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']); // Non-existing file $response = $this->actingAs($john)->put("api/v4/fs/1234", []); $response->assertStatus(404); // Unauthorized access $response = $this->actingAs($jack)->put("api/v4/fs/{$file->id}", []); $response->assertStatus(403); // Test name validation $post = ['name' => 'test/test.txt']; $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame(["The file name is invalid."], $json['errors']['name']); $post = ['name' => 'new name.txt', 'media' => 'test']; $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The specified media is invalid.", $json['errors']['media']); // Rename a file $post = ['name' => 'new namś.txt']; $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("File updated successfully.", $json['message']); $file->refresh(); $this->assertSame($post['name'], $file->getProperty('name')); // Update file content $body = "Test1\nTest2"; $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}?media=content", [], $body); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("File updated successfully.", $json['message']); $file->refresh(); $this->assertSame($body, $this->getTestFileContent($file)); $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 // TODO: Test media=resumable } /** * Test update item relations (PUT /api/v4/fs/$itemid) * Add/Remove/Set */ public function testUpdateRelation(): void { $john = $this->getTestUser('john@kolab.org'); $file = $this->getTestFile($john, 'test1.txt', 'Teśt content1', ['mimetype' => 'plain/text']); $collection1 = $this->getTestCollection($john, 'My Test Collection'); $collection2 = $this->getTestCollection($john, 'My Test Collection2'); // Add parents $headers = ["X-Kolab-Add-Parents" => implode(',', [$collection1->id])]; $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}", $headers, ''); $response->assertStatus(200); $this->assertSame('success', $response->json()['status']); $parents = $file->parents()->get(); $this->assertSame(1, count($parents)); $this->assertSame($collection1->id, $parents->first()->id); // Set parents $headers = ["X-Kolab-Parents" => implode(',', [$collection1->id, $collection2->id])]; $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}", $headers, ''); $response->assertStatus(200); $this->assertSame('success', $response->json()['status']); $parents = $file->parents()->get(); $this->assertSame(2, count($parents)); // Remove parents $headers = ["X-Kolab-Remove-Parents" => implode(',', [$collection1->id])]; $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}", $headers, ''); $response->assertStatus(200); $this->assertSame('success', $response->json()['status']); $parents = $file->parents()->get(); $this->assertSame(1, count($parents)); $this->assertSame($collection2->id, $parents->first()->id); } /** * Create a test file. * * @param \App\User $user File owner * @param string $name File name * @param string|array $content File content * @param array $props Extra file properties * * @return \App\Fs\Item */ protected function getTestFile(User $user, string $name, $content = [], $props = []): Item { $disk = LaravelStorage::disk(\config('filesystems.default')); $file = $user->fsItems()->create(['type' => Item::TYPE_FILE]); $size = 0; if (is_array($content) && empty($content)) { // do nothing, we don't need the body here } else { foreach ((array) $content as $idx => $chunk) { $chunkId = \App\Utils::uuidStr(); $path = Storage::chunkLocation($chunkId, $file); $disk->write($path, $chunk); $size += strlen($chunk); $file->chunks()->create([ 'chunk_id' => $chunkId, 'sequence' => $idx, 'size' => strlen($chunk), ]); } } $properties = [ 'name' => $name, 'size' => $size, 'mimetype' => 'application/octet-stream', ]; $file->setProperties($props + $properties); return $file; } /** * Create a test collection. * * @param \App\User $user File owner * @param string $name File name * @param array $props Extra collection properties * * @return \App\Fs\Item */ protected function getTestCollection(User $user, string $name, $props = []): Item { $collection = $user->fsItems()->create(['type' => Item::TYPE_COLLECTION]); $properties = [ 'name' => $name, ]; $collection->setProperties($props + $properties); return $collection; } /** * Get contents of a test file. * * @param \App\Fs\Item $file File record * * @return string */ protected function getTestFileContent(Item $file): string { $content = ''; $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file, &$content) { $disk = LaravelStorage::disk(\config('filesystems.default')); $path = Storage::chunkLocation($chunk->chunk_id, $file); $content .= $disk->read($path); }); return $content; } /** * Create a test file permission. * * @param \App\Fs\Item $file The file * @param \App\User $user File owner * @param string $permission File permission * * @return \App\Fs\Property File permission property */ protected function getTestFilePermission(Item $file, User $user, string $permission): Property { $shareId = 'share-' . \App\Utils::uuidStr(); return $file->properties()->create([ 'key' => $shareId, 'value' => "{$user->email}:{$permission}", ]); } /** * Invoke a HTTP request with a custom raw body * * @param ?\App\User $user Authenticated user * @param string $method Request method (POST, PUT) * @param string $uri Request URL * @param array $headers Request headers * @param string $content Raw body content * * @return \Illuminate\Testing\TestResponse HTTP Response object */ protected function sendRawBody(?User $user, string $method, string $uri, array $headers, string $content) { $headers['Content-Length'] = strlen($content); $server = $this->transformHeadersToServerVars($headers); $cookies = $this->prepareCookiesForRequest(); if ($user) { return $this->actingAs($user)->call($method, $uri, [], $cookies, [], $server, $content); } else { // TODO: Make sure this does not use "acting user" set earlier return $this->call($method, $uri, [], $cookies, [], $server, $content); } } }