diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -108,6 +108,7 @@ REDIS_PORT=6379 OCTANE_HTTP_HOST=127.0.0.1 +SWOOLE_PACKAGE_MAX_LENGTH=10485760 PAYMENT_PROVIDER= MOLLIE_KEY= diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/Storage.php @@ -0,0 +1,268 @@ +path . '/' . $file->id; + + // TODO: Deleting files might be slow, consider marking as deleted and async job + + $disk->deleteDirectory($path); + + $file->chunks()->delete(); + } + + /** + * 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); + + // 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']) { + // 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 { + return $disk->mimeType($path); + } 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/Chunk.php b/src/app/Fs/Chunk.php new file mode 100644 --- /dev/null +++ b/src/app/Fs/Chunk.php @@ -0,0 +1,37 @@ + The attributes that are mass assignable */ + protected $fillable = ['item_id', 'chunk_id', 'sequence', 'deleted_at', 'size']; + + /** @var string Database table name */ + protected $table = 'fs_chunks'; + + + /** + * The item (file) the chunk belongs to. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function item() + { + return $this->belongsTo(Item::class); + } +} diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php new file mode 100644 --- /dev/null +++ b/src/app/Fs/Item.php @@ -0,0 +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/Fs/Property.php b/src/app/Fs/Property.php new file mode 100644 --- /dev/null +++ b/src/app/Fs/Property.php @@ -0,0 +1,33 @@ + The attributes that are mass assignable */ + protected $fillable = ['item_id', 'key', 'value']; + + /** @var string Database table name */ + protected $table = 'fs_properties'; + + + /** + * The item to which this property belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function item() + { + return $this->belongsTo(Item::class); + } +} diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php --- a/src/app/Handlers/Beta/Base.php +++ b/src/app/Handlers/Beta/Base.php @@ -4,6 +4,16 @@ class Base extends \App\Handlers\Base { + /** + * The entitleable class for this handler. + * + * @return string + */ + public static function entitleableClass(): string + { + return \App\User::class; + } + /** * Check if the SKU is available to the user/domain. * @@ -67,4 +77,15 @@ return true; } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } } diff --git a/src/app/Handlers/Beta/Distlists.php b/src/app/Handlers/Beta/Distlists.php --- a/src/app/Handlers/Beta/Distlists.php +++ b/src/app/Handlers/Beta/Distlists.php @@ -4,16 +4,6 @@ class Distlists extends Base { - /** - * The entitleable class for this handler. - * - * @return string - */ - public static function entitleableClass(): string - { - return \App\User::class; - } - /** * Check if the SKU is available to the user/domain. * @@ -35,15 +25,4 @@ return false; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } } diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php --- a/src/app/Handlers/Beta/Resources.php +++ b/src/app/Handlers/Beta/Resources.php @@ -4,16 +4,6 @@ class Resources extends Base { - /** - * The entitleable class for this handler. - * - * @return string - */ - public static function entitleableClass(): string - { - return \App\User::class; - } - /** * Check if the SKU is available to the user/domain. * @@ -35,15 +25,4 @@ return false; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } } diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php --- a/src/app/Handlers/Beta/SharedFolders.php +++ b/src/app/Handlers/Beta/SharedFolders.php @@ -4,16 +4,6 @@ class SharedFolders extends Base { - /** - * The entitleable class for this handler. - * - * @return string - */ - public static function entitleableClass(): string - { - return \App\User::class; - } - /** * Check if the SKU is available to the user/domain. * @@ -35,15 +25,4 @@ return false; } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } } diff --git a/src/app/Handlers/Files.php b/src/app/Handlers/Files.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Files.php @@ -0,0 +1,7 @@ +inputFile($id, null); + + if (is_int($file)) { + return $this->errorResponse($file); + } + + // FIXME: Here we're just deleting the file, but maybe it would be better/faster + // to mark the file (record in db) as deleted and invoke a job to + // delete it asynchronously? + + Storage::fileDelete($file); + + $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) { + 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'])); + + $props['size'] *= 1; // convert to int + $result += $props; + } + + return $result; + } +} diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -185,6 +185,7 @@ 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), + 'enableFiles' => in_array('files', $skus), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners diff --git a/src/app/Http/Middleware/ContentSecurityPolicy.php b/src/app/Http/Middleware/ContentSecurityPolicy.php --- a/src/app/Http/Middleware/ContentSecurityPolicy.php +++ b/src/app/Http/Middleware/ContentSecurityPolicy.php @@ -25,7 +25,7 @@ foreach ($headers as $opt => $header) { if ($value = \config("app.headers.{$opt}")) { - $next->header($header, $value); + $next->headers->set($header, $value); } } diff --git a/src/app/Rules/FileName.php b/src/app/Rules/FileName.php new file mode 100644 --- /dev/null +++ b/src/app/Rules/FileName.php @@ -0,0 +1,83 @@ +owner = $owner; + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute Attribute name + * @param mixed $name The value to validate + * + * @return bool + */ + public function passes($attribute, $name): bool + { + if (empty($name) || !is_string($name)) { + $this->message = \trans('validation.file-name-invalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($name) > 512) { + $this->message = \trans('validation.max.string', ['max' => 512]); + return false; + } + + // Non-allowed characters + if (preg_match('|[\x00-\x1F\/*"\x7F]|', $name)) { + $this->message = \trans('validation.file-name-invalid'); + return false; + } + + // Leading/trailing spaces, or all spaces + if (preg_match('|^\s+$|', $name) || preg_match('|^\s+|', $name) || preg_match('|\s+$|', $name)) { + $this->message = \trans('validation.file-name-invalid'); + return false; + } + + // FIXME: Should we require a dot? + + // Check if the name is unique + $exists = $this->owner->fsItems() + ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') + ->where('key', 'name') + ->where('value', $name) + ->exists(); + + if ($exists) { + $this->message = \trans('validation.file-name-exists'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -348,6 +348,16 @@ return null; } + /** + * Storage items for this user. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function fsItems() + { + return $this->hasMany(Fs\Item::class); + } + /** * Return groups controlled by the current user. * diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -27,6 +27,7 @@ use SettingsTrait; use UuidStrKeyTrait; + /** @var bool Indicates that the model should be timestamped or not */ public $timestamps = false; /** @var array The attributes' default values */ diff --git a/src/config/filesystems.php b/src/config/filesystems.php --- a/src/config/filesystems.php +++ b/src/config/filesystems.php @@ -35,6 +35,11 @@ 'root' => storage_path('app'), ], + 'files' => [ + 'driver' => 'local', + 'root' => storage_path('app/files'), + ], + 'pgp' => [ 'driver' => 'local', 'root' => storage_path('app/keys'), diff --git a/src/config/octane.php b/src/config/octane.php --- a/src/config/octane.php +++ b/src/config/octane.php @@ -230,7 +230,7 @@ 'swoole' => [ 'options' => [ 'log_file' => storage_path('logs/swoole_http.log'), - 'package_max_length' => 10 * 1024 * 1024, + 'package_max_length' => env('SWOOLE_PACKAGE_MAX_LENGTH', 10 * 1024 * 1024), 'enable_coroutine' => false, //FIXME the daemonize option does not work // 'daemonize' => env('OCTANE_DAEMONIZE', true), diff --git a/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php @@ -0,0 +1,89 @@ +string('id', 36)->primary(); + $table->bigInteger('user_id')->index(); + $table->integer('type')->unsigned()->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('cascade'); + } + ); + + Schema::create( + 'fs_properties', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('item_id', 36); + $table->string('key')->index(); + $table->text('value'); + + $table->timestamps(); + + $table->unique(['item_id', 'key']); + + $table->foreign('item_id')->references('id')->on('fs_items') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + + Schema::create( + 'fs_chunks', + function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('item_id', 36); + $table->string('chunk_id', 36); + $table->integer('sequence')->default(0); + $table->integer('size')->unsigned()->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['item_id', 'chunk_id']); + // $table->unique(['item_id', 'sequence', 'deleted_at']); + + $table->foreign('item_id')->references('id')->on('fs_items') + ->onUpdate('cascade')->onDelete('cascade'); + } + ); + + if (!\App\Sku::where('title', 'files')->first()) { + \App\Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fs_properties'); + Schema::dropIfExists('fs_chunks'); + Schema::dropIfExists('fs_items'); + } +}; diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -256,6 +256,22 @@ ]); } + // Check existence because migration might have added this already + $sku = Sku::where(['title' => 'files', 'tenant_id' => \config('app.tenant_id')])->first(); + + if (!$sku) { + Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } + // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -241,5 +241,19 @@ 'active' => true, ]); } + + // Check existence because migration might have added this already + if (!Sku::where('title', 'files')->first()) { + Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -161,7 +161,7 @@ // while the token is being refreshed this.refreshTimeout = setTimeout(() => { - axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { + axios.post('api/auth/refresh', { refresh_token: response.refresh_token }).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) @@ -255,7 +255,7 @@ this.errorPage(status, message) } }, - downloadFile(url) { + downloadFile(url, filename) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser @@ -263,13 +263,16 @@ axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') - const contentDisposition = response.headers['content-disposition'] - let filename = 'unknown' - if (contentDisposition) { - const match = contentDisposition.match(/filename="(.+)"/); - if (match.length === 2) { - filename = match[1]; + if (!filename) { + const contentDisposition = response.headers['content-disposition'] + filename = 'unknown' + + if (contentDisposition) { + const match = contentDisposition.match(/filename="?(.+)"?/); + if (match && match.length === 2) { + filename = match[1]; + } } } diff --git a/src/resources/js/files.js b/src/resources/js/files.js new file mode 100644 --- /dev/null +++ b/src/resources/js/files.js @@ -0,0 +1,191 @@ + +function FileAPI(params = {}) +{ + // Initial max size of a file chunk in an upload request + // Note: The value may change to the value provided by the server on the first upload. + // Note: That chunk size here is only body, Swoole's package_max_length is body + headers, + // so don't forget to subtract some margin (e.g. 8KB) + // FIXME: From my preliminary tests it looks like on the PHP side you need + // about 3-4 times as much memory as the request size when using Swoole + // (only 1 time without Swoole). And I didn't find a way to lower the memory usage, + // it looks like it happens before we even start to process the request in FilesController. + let maxChunkSize = 5 * 1024 * 1024 - 1024 * 8 + + const area = $(params.dropArea) + + // Add hidden input to the drop area, so we can handle upload by click + const input = $('') + .attr({type: 'file', multiple: true, style: 'visibility: hidden'}) + .on('change', event => { fileDropHandler(event) }) + .appendTo(area) + .get(0) + + // Register events on the upload area element + area.on('click', () => input.click()) + .on('drop', event => fileDropHandler(event)) + .on('dragenter dragleave drop', event => fileDragHandler(event)) + .on('dragover', event => event.preventDefault()) // prevents file from being opened on drop) + + // Handle dragging on the whole page, so we can style the area in a different way + $(document.documentElement).off('.fileapi') + .on('dragenter.fileapi dragleave.fileapi', event => area.toggleClass('dragactive')) + + // Handle dragging file(s) - style the upload area element + const fileDragHandler = (event) => { + if (event.type == 'drop') { + area.removeClass('dragover dragactive') + } else { + area[event.type == 'dragenter' ? 'addClass' : 'removeClass']('dragover') + } + } + + // Handler for both a ondrop event and file input onchange event + const fileDropHandler = (event) => { + let files = event.target.files || event.dataTransfer.files + + if (!files || !files.length) { + return + } + + // Prevent default behavior (prevent file from being opened on drop) + event.preventDefault(); + + // TODO: Check file size limit, limit number of files to upload at once? + + // For every file... + for (const file of files) { + const progress = { + id: Date.now(), + name: file.name, + total: file.size, + completed: 0 + } + + file.uploaded = 0 + + // Upload request configuration + const config = { + onUploadProgress: progressEvent => { + progress.completed = Math.round(((file.uploaded + progressEvent.loaded) * 100) / file.size) + + // Don't trigger the event when 100% of the file has been sent + // We need to wait until the request response is available, then + // we'll trigger it (see below where the axios request is created) + if (progress.completed < 100) { + params.eventHandler('upload-progress', progress) + } + }, + headers: { + 'Content-Type': file.type + }, + ignoreErrors: true, // skip the Kolab4 interceptor + params: { name: file.name }, + maxBodyLength: -1, // no limit + timeout: 0, // no limit + transformRequest: [] // disable body transformation + } + + // FIXME: It might be better to handle multiple-files upload as a one + // "progress source", i.e. we might want to refresh the files list once + // all files finished uploading, not multiple times in the middle + // of the upload process. + + params.eventHandler('upload-progress', progress) + + // A "recursive" function to upload the file in chunks (if needed) + const uploadFn = (start = 0, uploadId) => { + let url = 'api/v4/files' + let body = '' + + if (file.size <= maxChunkSize) { + // The file is small, we'll upload it using a single request + // Note that even in this case the auth token might expire while + // the file is uploading, but the risk is quite small. + body = file + start += maxChunkSize + } else if (!uploadId) { + // The file is big, first send a request for the upload location + // The upload location does not require authentication, which means + // there should be no problem with expired auth token, etc. + config.params.media = 'resumable' + config.params.size = file.size + } else { + // Upload a chunk of the file to the upload location + url = 'api/v4/files/uploads/' + uploadId + body = file.slice(start, start + maxChunkSize, file.type) + + config.params = { from: start } + config.headers.Authorization = '' + start += maxChunkSize + } + + axios.post(url, body, config) + .then(response => { + if (response.data.maxChunkSize) { + maxChunkSize = response.data.maxChunkSize + } + + if (start < file.size) { + file.uploaded = start + uploadFn(start, uploadId || response.data.uploadId) + } else { + progress.completed = 100 + params.eventHandler('upload-progress', progress) + } + }) + .catch(error => { + console.log(error) + + // TODO: Depending on the error consider retrying the request + // if it was one of many chunks of a bigger file? + + progress.error = error + progress.completed = 100 + params.eventHandler('upload-progress', progress) + }) + } + + // Start uploading + uploadFn() + } + } + + /** + * Download a file. Starts downloading using a hidden link trick. + */ + this.fileDownload = (id) => { + axios.get('api/v4/files/' + id + '?downloadUrl=1') + .then(response => { + // Create a dummy link element and click it + if (response.data.downloadUrl) { + $('').attr('href', response.data.downloadUrl).get(0).click() + } + }) + } + + /** + * Rename a file. + */ + this.fileRename = (id, name) => { + axios.put('api/v4/files/' + id, { name }) + .then(response => { + + }) + } + + /** + * Convert file size as a number of bytes to a human-readable format + */ + this.sizeText = (bytes) => { + if (bytes >= 1073741824) + return parseFloat(bytes/1073741824).toFixed(2) + ' GB'; + if (bytes >= 1048576) + return parseFloat(bytes/1048576).toFixed(2) + ' MB'; + if (bytes >= 1024) + return parseInt(bytes/1024) + ' kB'; + + return parseInt(bytes || 0) + ' B'; + } +} + +export default FileAPI diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -14,6 +14,8 @@ const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info') const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List') +const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info') +const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List') const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms') const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info') const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List') @@ -64,6 +66,18 @@ component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, + { + path: '/file/:file', + name: 'file', + component: FileInfoComponent, + meta: { requiresAuth: true /*, perm: 'files' */ } + }, + { + path: '/files', + name: 'files', + component: FileListComponent, + meta: { requiresAuth: true, perm: 'files' } + }, { path: '/login', name: 'login', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -70,6 +70,13 @@ 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', + 'file-create-success' => 'File created successfully.', + 'file-delete-success' => 'File deleted successfully.', + 'file-update-success' => 'File updated successfully.', + 'file-permissions-create-success' => 'File permissions created successfully.', + 'file-permissions-update-success' => 'File permissions updated successfully.', + 'file-permissions-delete-success' => 'File permissions deleted successfully.', + 'resource-update-success' => 'Resource updated successfully.', 'resource-create-success' => 'Resource created successfully.', 'resource-delete-success' => 'Resource deleted successfully.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -31,6 +31,7 @@ 'resend' => "Resend", 'save' => "Save", 'search' => "Search", + 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", @@ -60,6 +61,7 @@ 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", + 'files' => "Files", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", @@ -126,6 +128,19 @@ 'form' => "Form validation error", ], + 'file' => [ + 'create' => "Create file", + 'delete' => "Delete file", + 'list-empty' => "There are no files in this account.", + 'mimetype' => "Mimetype", + 'mtime' => "Modified", + 'new' => "New file", + 'search' => "File name", + 'sharing' => "Sharing", + 'sharing-links-text' => "You can share the file with other users by giving them read-only access " + . "to the file via a unique link.", + ], + 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", @@ -154,6 +169,7 @@ 'phone' => "Phone", 'settings' => "Settings", 'shared-folder' => "Shared Folder", + 'size' => "Size", 'status' => "Status", 'surname' => "Surname", 'type' => "Type", @@ -302,6 +318,7 @@ 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", + 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -153,6 +153,11 @@ 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', + 'file-perm-exists' => 'File permission already exists.', + 'file-perm-invalid' => 'The file permission is invalid.', + 'file-name-exists' => 'The file name already exists.', + 'file-name-invalid' => 'The file name is invalid.', + 'file-name-toolong' => 'The file name is too long.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -137,7 +137,9 @@ td.buttons, th.price, - td.price { + td.price, + th.size, + td.size { width: 1%; text-align: right; white-space: nowrap; @@ -174,6 +176,38 @@ margin-left: .4em; } } + + &.files { + table-layout: fixed; + + td { + white-space: nowrap; + } + + td.name { + overflow: hidden; + text-overflow: ellipsis; + } +/* + td.size, + th.size { + width: 80px; + } + + td.mtime, + th.mtime { + width: 140px; + + @include media-breakpoint-down(sm) { + display: none; + } + } +*/ + td.buttons, + th.buttons { + width: 50px; + } + } } .list-details { diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -169,3 +169,32 @@ } } } + +.file-drop-area { + display: inline; + background: $menu-bg-color; + color: grey; + font-size: 0.9rem; + font-weight: normal; + line-height: 2; + border: 1px solid #eee; + border-radius: 0.5em; + padding: 0.5em; + cursor: pointer; + position: relative; + + input { + position: absolute; + height: 10px; + } + + &.dragactive { + border: 1px dashed #aaa; + } + + &.dragover { + background-color: rgba($main-color, 0.25); + border: 1px dashed $main-color; + color: $main-color; + } +} diff --git a/src/resources/themes/toast.scss b/src/resources/themes/toast.scss --- a/src/resources/themes/toast.scss +++ b/src/resources/themes/toast.scss @@ -51,3 +51,17 @@ .toast-body { color: #fff; } + +.toast-progress { + margin: 0.5em; + margin-top: 0; + height: 3px; + background: #222; + border-radius: 1.5px; + overflow: hidden; +} + +.toast-progress-bar { + height: 100%; + background: $main-color; +} diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -32,6 +32,10 @@ {{ $t('dashboard.chat') }} {{ $t('dashboard.beta') }} + + {{ $t('dashboard.files') }} + {{ $t('dashboard.beta') }} + {{ $t('dashboard.settings') }} diff --git a/src/resources/vue/File/Info.vue b/src/resources/vue/File/Info.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/File/Info.vue @@ -0,0 +1,163 @@ + + + diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/File/List.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue --- a/src/resources/vue/Widgets/AclInput.vue +++ b/src/resources/vue/Widgets/AclInput.vue @@ -1,7 +1,7 @@ @@ -52,9 +55,15 @@ return this.data.titleClassName || '' } }, + delete() { + new Toast(this.$el).dispose() + }, toastClassName() { return 'toast hide toast-' + this.data.type + (this.data.className ? ' ' + this.data.className : '') + }, + updateProgress(percent) { + $(this.$el).find('.toast-progress-bar').css('width', percent + '%') } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -81,6 +81,17 @@ Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); + Route::apiResource('files', API\V4\FilesController::class); + Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']); + Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']); + Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']); + Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']); + Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload']) + ->withoutMiddleware(['auth:api']) + ->middleware(['api']); + Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download']) + ->withoutMiddleware(['auth:api']); + Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); diff --git a/src/tests/Feature/Controller/FilesTest.php b/src/tests/Feature/Controller/FilesTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/FilesTest.php @@ -0,0 +1,732 @@ +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'); + + // 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']); + + // Test that the file has been removed from the filesystem? + $disk = LaravelStorage::disk('files'); + $this->assertFalse($disk->directoryExists($file->path . '/' . $file->id)); + $this->assertSame(null, Item::find($file->id)); + + // Test deletion of a chunked file + $file = $this->getTestFile($john, 'test.txt', ['T1', 'T2']); + + $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']); + + // Test that the file has been removed from the filesystem? + $disk = LaravelStorage::disk('files'); + $this->assertFalse($disk->directoryExists($file->path . '/' . $file->id)); + $this->assertSame(null, Item::find($file->id)); + + // 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'); + + // 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') . '; charset=UTF-8'); + + $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') . '; charset=UTF-8'); + + $this->assertSame('Teśt content', $response->streamedContent()); + + // Test downloading a multi-chunk file + $file = $this->getTestFile($john, 'test2.txt', ['T1', 'T2']); + $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 + } + + /** + * 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'); + + // 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') . '; charset=UTF-8'); + + $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->assertSame('text/plain', $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->assertSame('text/plain', $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'); + + // 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->assertSame('text/plain', $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; + $mimetype = ''; + + 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); + + if (!$size) { + $mimetype = $disk->mimeType($path); + } + + $size += strlen($chunk); + + $file->chunks()->create([ + 'chunk_id' => $chunkId, + 'sequence' => $idx, + 'size' => strlen($chunk), + ]); + } + } + + $properties = [ + 'name' => $name, + 'size' => $size, + 'mimetype' => $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); + } + } +}