diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php index 813e0e74..21d50caa 100644 --- a/src/app/Backends/Storage.php +++ b/src/app/Backends/Storage.php @@ -1,288 +1,298 @@ path . '/' . $file->id; // TODO: Deleting files might be slow, consider marking as deleted and async job $disk->deleteDirectory($path); $file->forceDelete(); } /** * Delete a file chunk. * * @param \App\Fs\Chunk $chunk File chunk object * * @throws \Exception */ public static function fileChunkDelete(Chunk $chunk): void { $disk = LaravelStorage::disk('files'); $path = self::chunkLocation($chunk->chunk_id, $chunk->item); $disk->delete($path); $chunk->forceDelete(); } /** * File download handler. * * @param \App\Fs\Item $file File object * * @throws \Exception */ public static function fileDownload(Item $file): StreamedResponse { $response = new StreamedResponse(); $props = $file->getProperties(['name', 'size', 'mimetype']); // Prepare the file name for the Content-Disposition header $extension = pathinfo($props['name'], \PATHINFO_EXTENSION) ?: 'file'; $fallbackName = str_replace('%', '', Str::ascii($props['name'])) ?: "file.{$extension}"; $disposition = $response->headers->makeDisposition('attachment', $props['name'], $fallbackName); $response->headers->replace([ 'Content-Length' => $props['size'] ?: 0, 'Content-Type' => $props['mimetype'], 'Content-Disposition' => $disposition, ]); $response->setCallback(function () use ($file) { $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file) { $disk = LaravelStorage::disk('files'); $path = Storage::chunkLocation($chunk->chunk_id, $file); $stream = $disk->readStream($path); fpassthru($stream); fclose($stream); }); }); return $response; } /** * File upload handler * * @param resource $stream File input stream * @param array $params Request parameters * @param ?\App\Fs\Item $file The file object * * @return array File/Response attributes * @throws \Exception */ public static function fileInput($stream, array $params, Item $file = null): array { if (!empty($params['uploadId'])) { return self::fileInputResumable($stream, $params, $file); } $disk = LaravelStorage::disk('files'); $chunkId = \App\Utils::uuidStr(); $path = self::chunkLocation($chunkId, $file); $disk->writeStream($path, $stream); $fileSize = $disk->fileSize($path); + if ($file->type & Item::TYPE_INCOMPLETE) { + $file->type -= Item::TYPE_INCOMPLETE; + $file->save(); + } + // Update the file type and size information $file->setProperties([ 'size' => $fileSize, 'mimetype' => self::mimetype($path), ]); // Assign the node to the file, "unlink" any old nodes of this file $file->chunks()->delete(); $file->chunks()->create([ 'chunk_id' => $chunkId, 'sequence' => 0, 'size' => $fileSize, ]); return ['id' => $file->id]; } /** * Resumable file upload handler * * @param resource $stream File input stream * @param array $params Request parameters * @param ?\App\Fs\Item $file The file object * * @return array File/Response attributes * @throws \Exception */ protected static function fileInputResumable($stream, array $params, Item $file = null): array { // Initial request, save file metadata, return uploadId if ($params['uploadId'] == 'resumable') { if (empty($params['size']) || empty($file)) { throw new \Exception("Missing parameters of resumable file upload."); } $params['uploadId'] = \App\Utils::uuidStr(); $upload = [ 'fileId' => $file->id, 'size' => $params['size'], 'uploaded' => 0, ]; if (!Cache::add('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL)) { throw new \Exception("Failed to create cache entry for resumable file upload."); } return [ 'uploadId' => $params['uploadId'], 'uploaded' => 0, 'maxChunkSize' => (\config('octane.swoole.options.package_max_length') ?: 10 * 1024 * 1024) - 8192, ]; } $upload = Cache::get('upload:' . $params['uploadId']); if (empty($upload)) { throw new \Exception("Cache entry for resumable file upload does not exist."); } $file = Item::find($upload['fileId']); if (!$file) { throw new \Exception("Invalid fileId for resumable file upload."); } $from = $params['from'] ?? 0; // Sanity checks on the input parameters // TODO: Support uploading again a chunk that already has been uploaded? if ($from < $upload['uploaded'] || $from > $upload['uploaded'] || $from > $upload['size']) { throw new \Exception("Invalid 'from' parameter for resumable file upload."); } $disk = LaravelStorage::disk('files'); $chunkId = \App\Utils::uuidStr(); $path = self::chunkLocation($chunkId, $file); // Save the file chunk $disk->writeStream($path, $stream); // Detect file type using the first chunk if ($from == 0) { $upload['mimetype'] = self::mimetype($path); $upload['chunks'] = []; } $chunkSize = $disk->fileSize($path); // Create the chunk record $file->chunks()->create([ 'chunk_id' => $chunkId, 'sequence' => count($upload['chunks']), 'size' => $chunkSize, 'deleted_at' => \now(), // not yet active chunk ]); $upload['chunks'][] = $chunkId; $upload['uploaded'] += $chunkSize; // Update the file metadata after the upload of all chunks is completed if ($upload['uploaded'] >= $upload['size']) { + if ($file->type & Item::TYPE_INCOMPLETE) { + $file->type -= Item::TYPE_INCOMPLETE; + $file->save(); + } + // Update file metadata $file->setProperties([ 'size' => $upload['uploaded'], 'mimetype' => $upload['mimetype'] ?: 'application/octet-stream', ]); // Assign uploaded chunks to the file, "unlink" any old chunks of this file $file->chunks()->delete(); $file->chunks()->whereIn('chunk_id', $upload['chunks'])->restore(); // TODO: Create a "cron" job to remove orphaned nodes from DB and the storage. // I.e. all with deleted_at set and older than UPLOAD_TTL // Delete the upload cache record Cache::forget('upload:' . $params['uploadId']); return ['id' => $file->id]; } // Update the upload metadata Cache::put('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL); return ['uploadId' => $params['uploadId'], 'uploaded' => $upload['uploaded']]; } /** * Get the file mime type. * * @param string $path File location * * @return string File mime type */ protected static function mimetype(string $path): string { $disk = LaravelStorage::disk('files'); // TODO: If file is empty, detect the mimetype based on the extension? try { $mimetype = $disk->mimeType($path); // The mimetype may contain e.g. "; charset=UTF-8", remove this return preg_replace('/;.*$/', '', $mimetype); } catch (\Exception $e) { // do nothing } // TODO: If it fails detect the mimetype based on extension? return 'application/octet-stream'; } /** * Node location in the storage * * @param string $chunkId Chunk identifier * @param \App\Fs\Item $file File the chunk belongs to * * @return string Chunk location */ public static function chunkLocation(string $chunkId, Item $file): string { return $file->path . '/' . $file->id . '/' . $chunkId; } } diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php index 422f08d5..6d1438e3 100644 --- a/src/app/Fs/Item.php +++ b/src/app/Fs/Item.php @@ -1,178 +1,178 @@ The attributes that are mass assignable */ protected $fillable = ['user_id', 'type']; /** @var array The attributes that should be cast */ protected $casts = [ 'created_at' => 'datetime:Y-m-d H:i:s', 'updated_at' => 'datetime:Y-m-d H:i:s', ]; /** @var string Database table name */ protected $table = 'fs_items'; /** * COntent chunks of this item (file). * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function chunks() { return $this->hasMany(Chunk::class); } /** * Getter for the file path (without the filename) in the storage. * * @return \Illuminate\Database\Eloquent\Casts\Attribute */ protected function path(): Attribute { return Attribute::make( get: function ($value) { if (empty($this->id)) { throw new \Exception("Cannot get path for an item without ID"); } $id = substr($this->id, 0, 6); return implode('/', str_split($id, 2)); } ); } /** * Any (additional) properties of this item. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function properties() { return $this->hasMany(Property::class); } /** * Obtain the value for an item property * * @param string $key Property name * @param mixed $default Default value, to be used if not found * * @return string|null Property value */ public function getProperty(string $key, $default = null) { $attr = $this->properties()->where('key', $key)->first(); return $attr ? $attr->value : $default; } /** * Obtain the values for many properties in one go (for better performance). * * @param array $keys Property names * * @return array Property key=value hash, includes also requested but non-existing properties */ public function getProperties(array $keys): array { $props = array_fill_keys($keys, null); $this->properties()->whereIn('key', $keys)->get() ->each(function ($prop) use (&$props) { $props[$prop->key] = $prop->value; }); return $props; } /** * Remove a property * * @param string $key Property name */ public function removeProperty(string $key): void { $this->setProperty($key, null); } /** * Create or update a property. * * @param string $key Property name * @param string|null $value The new value for the property */ public function setProperty(string $key, $value): void { $this->storeProperty($key, $value); } /** * Create or update multiple properties in one fell swoop. * * @param array $data An associative array of key value pairs. */ public function setProperties(array $data = []): void { foreach ($data as $key => $value) { $this->storeProperty($key, $value); } } /** * Create or update a property. * * @param string $key Property name * @param string|null $value The new value for the property */ private function storeProperty(string $key, $value): void { if ($value === null || $value === '') { // Note: We're selecting the record first, so observers can act if ($prop = $this->properties()->where('key', $key)->first()) { $prop->delete(); } } else { $this->properties()->updateOrCreate( ['key' => $key], ['value' => $value] ); } } /** * The user to which this item belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { return $this->belongsTo(User::class, 'user_id', 'id'); } } diff --git a/src/app/Http/Controllers/API/V4/FilesController.php b/src/app/Http/Controllers/API/V4/FilesController.php index 8a1219c9..eef2aaec 100644 --- a/src/app/Http/Controllers/API/V4/FilesController.php +++ b/src/app/Http/Controllers/API/V4/FilesController.php @@ -1,608 +1,608 @@ inputFile($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(); return response()->json([ 'status' => 'success', 'message' => \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->inputFile($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->inputFile($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'] = \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'] = \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' => \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->inputFile($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' => \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->inputFile($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'] = \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'] = \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' => \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; $pageSize = 100; $hasMore = false; $user = $this->guard()->user(); $result = $user->fsItems()->select('fs_items.*', 'fs_properties.value as name') ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') ->whereNot('type', '&', Item::TYPE_INCOMPLETE) ->where('key', 'name'); 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->inputFile($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/files/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) { $user = $this->guard()->user(); // Validate file name input $v = Validator::make($request->all(), ['name' => ['required', new FileName($user)]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $filename = $request->input('name'); $media = $request->input('media'); // FIXME: Normally people just drag and drop/upload files. // The client side will not know whether the file with the same name // already exists or not. So, in such a case should we throw // an error or accept the request as an update? $params = []; if ($media == 'resumable') { $params['uploadId'] = 'resumable'; $params['size'] = $request->input('size'); $params['from'] = $request->input('from') ?: 0; } // TODO: Delete the existing incomplete file with the same name? $file = $user->fsItems()->create(['type' => Item::TYPE_INCOMPLETE | Item::TYPE_FILE]); $file->setProperty('name', $filename); try { $response = Storage::fileInput($request->getContent(true), $params, $file); $response['status'] = 'success'; if (!empty($response['id'])) { $response += $this->objectToClient($file, true); $response['message'] = \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->inputFile($id, self::WRITE); if (is_int($file)) { return $this->errorResponse($file); } $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($file->user)]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $file->setProperty('name', $filename); } // $file->save(); } 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' => \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'] = \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'] = \trans('app.file-upload-success'); } } catch (\Exception $e) { \Log::error($e); return $this->errorResponse(500); } return response()->json($response); } /** * 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 inputFile($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) { + if (!$file || !($file->type & Item::TYPE_FILE) || ($file->type & Item::TYPE_INCOMPLETE)) { return 404; } if (!$isShare && $user->id != $file->user_id) { return 403; } 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 ($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; } return $result; } } diff --git a/src/tests/Feature/Controller/FilesTest.php b/src/tests/Feature/Controller/FilesTest.php index 65669ece..e85f3ee8 100644 --- a/src/tests/Feature/Controller/FilesTest.php +++ b/src/tests/Feature/Controller/FilesTest.php @@ -1,712 +1,724 @@ delete(); } /** * {@inheritDoc} */ public function tearDown(): void { Item::query()->delete(); $disk = LaravelStorage::disk('files'); foreach ($disk->listContents('') as $dir) { $disk->deleteDirectory($dir->path()); } parent::tearDown(); } /** * Test deleting files (DELETE /api/v4/files/) */ 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/files/{$file->id}"); $response->assertStatus(401); // Unauth access $response = $this->actingAs($jack)->delete("api/v4/files/{$file->id}"); $response->assertStatus(403); // Non-existing file $response = $this->actingAs($john)->delete("api/v4/files/123"); $response->assertStatus(404); // File owner access $response = $this->actingAs($john)->delete("api/v4/files/{$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 file downloads (GET /api/v4/files/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/files/{$file->id}?downloadUrl=1"); $response->assertStatus(401); $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}?downloadUrl=1"); $response->assertStatus(403); // Non-existing file $response = $this->actingAs($john)->get("api/v4/files/123456?downloadUrl=1"); $response->assertStatus(404); // Get downloadLink for the file $response = $this->actingAs($john)->get("api/v4/files/{$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-Length', $file->getProperty('size')) ->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/files/{$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-Length', $file->getProperty('size')) ->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/files/{$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-Length', $file->getProperty('size')) ->assertHeader('Content-Type', $file->getProperty('mimetype')); $this->assertSame('T1T2', $response->streamedContent()); } /** * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/files//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/files/{$file->id}/permissions"); $response->assertStatus(401); $response = $this->post("api/v4/files/{$file->id}/permissions", []); $response->assertStatus(401); // Non-existing file $response = $this->actingAs($john)->get("api/v4/files/1234/permissions"); $response->assertStatus(404); $response = $this->actingAs($john)->post("api/v4/files/1234/permissions", []); $response->assertStatus(404); // No permissions to the file $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}/permissions"); $response->assertStatus(403); $response = $this->actingAs($jack)->post("api/v4/files/{$file->id}/permissions", []); $response->assertStatus(403); // Expect an empty list of permissions $response = $this->actingAs($john)->get("api/v4/files/{$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/files/{$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/files/{$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/files/{$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/files/{$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/files/{$file->id}/permissions/1234", $post); $response->assertStatus(404); $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-write']; $response = $this->actingAs($john)->put("api/v4/files/{$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/files/{$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/files/{$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/files/{$file->id}/permissions/1234"); $response->assertStatus(404); $response = $this->actingAs($john)->delete("api/v4/files/{$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 files/folders list (GET /api/v4/files) */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/files"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); // Expect an empty list $response = $this->actingAs($user)->get("api/v4/files"); $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/files"); $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/files?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/files"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(3, $json); + $this->assertSame(1, $json['count']); } /** * Test fetching file metadata (GET /api/v4/files/) */ public function testShow(): void { // Unauth access not allowed $response = $this->get("api/v4/files/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/files/1234"); $response->assertStatus(404); // Unauthorized access $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}"); $response->assertStatus(403); // Get file metadata $response = $this->actingAs($john)->get("api/v4/files/{$file->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($file->id, $json['id']); $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/files/{$file->id}?download=1"); $response->assertStatus(200) ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt") ->assertHeader('Content-Length', $file->getProperty('size')) ->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/files/{$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/files) */ public function testStore(): void { // Unauth access not allowed $response = $this->post("api/v4/files"); $response->assertStatus(401); $john = $this->getTestUser('john@kolab.org'); // Test input validation $response = $this->sendRawBody($john, 'POST', "api/v4/files", [], ''); $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/files?name=*.txt", [], ''); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame(["The file name is invalid."], $json['errors']['name']); // Create a file - the simple method $body = "test content"; $headers = []; $response = $this->sendRawBody($john, 'POST', "api/v4/files?name=test.txt", $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)); } /** * Test creating files - resumable (POST /api/v4/files) */ public function testStoreResumable(): void { $john = $this->getTestUser('john@kolab.org'); $response = $this->actingAs($john)->post("api/v4/files?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'] ); $uploadId = $json['uploadId']; $size = 0; $fileContent = ''; for ($x = 0; $x <= 2; $x++) { $body = str_repeat("$x", 100); $response = $this->sendRawBody(null, 'POST', "api/v4/files/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/files/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/files/) */ public function testUpdate(): void { // Unauth access not allowed $response = $this->put("api/v4/files/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/files/1234", []); $response->assertStatus(404); // Unauthorized access $response = $this->actingAs($jack)->put("api/v4/files/{$file->id}", []); $response->assertStatus(403); // Test name validation $post = ['name' => 'test/test.txt']; $response = $this->actingAs($john)->put("api/v4/files/{$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/files/{$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/files/{$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/files/{$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 } /** * 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('files'); $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; } /** * 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('files'); $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); } } }