diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php --- a/src/app/Backends/Storage.php +++ b/src/app/Backends/Storage.php @@ -126,7 +126,8 @@ // Update the file type and size information $file->setProperties([ 'size' => $fileSize, - 'mimetype' => self::mimetype($path), + // Pick the client-supplied mimetype if available, otherwise detect. + 'mimetype' => !empty($params['mimetype']) ? $params['mimetype'] : self::mimetype($path), ]); // Assign the node to the file, "unlink" any old nodes of this file diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php --- a/src/app/Fs/Item.php +++ b/src/app/Fs/Item.php @@ -24,7 +24,7 @@ use UuidStrKeyTrait; public const TYPE_FILE = 1; - public const TYPE_FOLDER = 2; + public const TYPE_COLLECTION = 2; public const TYPE_INCOMPLETE = 4; /** @var array The attributes that are mass assignable */ @@ -167,4 +167,38 @@ ); } } + + /** + * All relations for this item + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function relations() + { + return $this->hasMany(Relation::class); + } + + /** + * Child relations for this item + * + * Used to retrieve all items in a collection. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function children() + { + return $this->belongsToMany(Item::class, 'fs_relations', 'item_id', 'related_id'); + } + + /** + * Parent relations for this item + * + * Used to retrieve all collections of an item. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function parents() + { + return $this->belongsToMany(Item::class, 'fs_relations', 'related_id', 'item_id'); + } } diff --git a/src/app/Fs/Relation.php b/src/app/Fs/Relation.php new file mode 100644 --- /dev/null +++ b/src/app/Fs/Relation.php @@ -0,0 +1,21 @@ + The attributes that are mass assignable */ + protected $fillable = ['item_id', 'related_id']; + + /** @var string Database table name */ + protected $table = 'fs_relations'; +} diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php --- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -247,7 +247,7 @@ 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, - 'allowed_scopes' => "mfa" + 'allowed_scopes' => ["mfa", "fs"] ]); $client->save(); diff --git a/src/app/Http/Controllers/API/V4/FilesController.php b/src/app/Http/Controllers/API/V4/FsController.php rename from src/app/Http/Controllers/API/V4/FilesController.php rename to src/app/Http/Controllers/API/V4/FsController.php --- a/src/app/Http/Controllers/API/V4/FilesController.php +++ b/src/app/Http/Controllers/API/V4/FsController.php @@ -13,11 +13,15 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; -class FilesController extends RelationController +class FsController extends RelationController { protected const READ = 'r'; protected const WRITE = 'w'; + protected const TYPE_COLLECTION = 'collection'; + protected const TYPE_FILE = 'file'; + protected const TYPE_UNKNOWN = 'unknown'; + /** @var string Resource localization label */ protected $label = 'file'; @@ -35,7 +39,7 @@ public function destroy($id) { // Only the file owner can do that, for now - $file = $this->inputFile($id, null); + $file = $this->inputItem($id, null); if (is_int($file)) { return $this->errorResponse($file); @@ -45,6 +49,13 @@ // storage later with the fs:expunge command $file->delete(); + if ($file->type & Item::TYPE_COLLECTION) { + return response()->json([ + 'status' => 'success', + 'message' => self::trans('app.collection-delete-success'), + ]); + } + return response()->json([ 'status' => 'success', 'message' => self::trans('app.file-delete-success'), @@ -85,7 +96,7 @@ public function getPermissions($fileId) { // Only the file owner can do that, for now - $file = $this->inputFile($fileId, null); + $file = $this->inputItem($fileId, null); if (is_int($file)) { return $this->errorResponse($file); @@ -113,7 +124,7 @@ public function createPermission($fileId) { // Only the file owner can do that, for now - $file = $this->inputFile($fileId, null); + $file = $this->inputItem($fileId, null); if (is_int($file)) { return $this->errorResponse($file); @@ -174,7 +185,7 @@ public function deletePermission($fileId, $id) { // Only the file owner can do that, for now - $file = $this->inputFile($fileId, null); + $file = $this->inputItem($fileId, null); if (is_int($file)) { return $this->errorResponse($file); @@ -206,7 +217,7 @@ public function updatePermission(Request $request, $fileId, $id) { // Only the file owner can do that, for now - $file = $this->inputFile($fileId, null); + $file = $this->inputItem($fileId, null); if (is_int($file)) { return $this->errorResponse($file); @@ -264,15 +275,35 @@ { $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; + $parent = request()->input('parent'); + $type = request()->input('type'); $pageSize = 100; $hasMore = false; $user = $this->guard()->user(); - $result = $user->fsItems()->select('fs_items.*', 'fs_properties.value as name') - ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') - ->whereNot('type', '&', Item::TYPE_INCOMPLETE) - ->where('key', 'name'); + $result = $user->fsItems()->select('fs_items.*', 'fs_properties.value as name'); + + if ($parent) { + $result->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id') + ->where('fs_relations.item_id', $parent); + } else { + $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id') + ->whereNull('fs_relations.related_id'); + } + + // Add properties + $result->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id') + ->whereNot('type', '&', Item::TYPE_INCOMPLETE) + ->where('key', 'name'); + + if ($type) { + if ($type == self::TYPE_COLLECTION) { + $result->where('type', '&', Item::TYPE_COLLECTION); + } else { + $result->where('type', '&', Item::TYPE_FILE); + } + } if (strlen($search)) { $result->whereLike('fs_properties.value', $search); @@ -316,7 +347,7 @@ */ public function show($id) { - $file = $this->inputFile($id, self::READ); + $file = $this->inputItem($id, self::READ); if (is_int($file)) { return $this->errorResponse($file); @@ -328,7 +359,7 @@ // 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); + $response['downloadUrl'] = Utils::serviceUrl('api/v4/fs/downloads/' . $downloadId); } elseif (request()->input('download')) { // Return the file content return Storage::fileDownload($file); @@ -345,6 +376,92 @@ return response()->json($response); } + private function deduplicateOrCreate(Request $request, $type) + { + $user = $this->guard()->user(); + $item = null; + if ($request->has('deduplicate-property')) { + //query for item by deduplicate-value + $result = $user->fsItems()->select('fs_items.*'); + $result->join('fs_properties', function ($join) use ($request) { + $join->on('fs_items.id', '=', 'fs_properties.item_id') + ->where('fs_properties.key', $request->input('deduplicate-property')); + }) + ->where('type', '&', $type); + + $result->whereLike('fs_properties.value', $request->input('deduplicate-value')); + $item = $result->first(); + } + + if (!$item) { + $item = $user->fsItems()->create(['type' => $type]); + } + return $item; + } + + /** + * Create a new collection. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + private function createCollection(Request $request) + { + // Validate file name input + $v = Validator::make($request->all(), [ + 'name' => ['required', new FileName()], + 'deviceId' => ['max:255'], + 'collectionType' => ['max:255'], + ]); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $properties = [ + 'name' => $request->input('name'), + 'deviceId' => $request->input('deviceId'), + 'collectionType' => $request->input('collectionType'), + ]; + + foreach ($request->all() as $key => $value) { + if (str_starts_with($key, "property-")) { + $propertyKey = substr($key, 9); + if (strlen($propertyKey) > 191) { + return response()->json([ + 'status' => 'error', + 'errors' => [self::trans('validation.max.string', ['attribute' => $propertyKey, 'max' => 191])] + ], 422); + } + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $propertyKey)) { + return response()->json([ + 'status' => 'error', + 'errors' => [self::trans('validation.regex_format', [ + 'attribute' => $propertyKey, + 'format' => "a-zA-Z0-9_-" + ])] + ], 422); + } + $properties[$propertyKey] = $value; + } + } + + $item = $this->deduplicateOrCreate($request, Item::TYPE_COLLECTION); + $item->setProperties($properties); + + if ($parent = $request->input('parent')) { + $item->parents()->sync([$parent]); + } + + $response = []; + $response['status'] = 'success'; + $response['id'] = $item->id; + $response['message'] = self::trans('app.collection-create-success'); + + return response()->json($response); + } + /** * Create a new file. * @@ -354,10 +471,13 @@ */ public function store(Request $request) { - $user = $this->guard()->user(); + $type = $request->input('type'); + if ($type == self::TYPE_COLLECTION) { + return $this->createCollection($request); + } // Validate file name input - $v = Validator::make($request->all(), ['name' => ['required', new FileName($user)]]); + $v = Validator::make($request->all(), ['name' => ['required', new FileName()]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); @@ -366,12 +486,8 @@ $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 = []; + $params['mimetype'] = $request->headers->get('Content-Type', null); if ($media == 'resumable') { $params['uploadId'] = 'resumable'; @@ -380,9 +496,41 @@ } // TODO: Delete the existing incomplete file with the same name? + $properties = ['name' => $filename]; + + foreach ($request->all() as $key => $value) { + if (str_starts_with($key, "property-")) { + $propertyKey = substr($key, 9); + if (strlen($propertyKey) > 191) { + return response()->json([ + 'status' => 'error', + 'errors' => [self::trans('validation.max.string', ['attribute' => $propertyKey, 'max' => 191])] + ], 422); + } + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $propertyKey)) { + return response()->json([ + 'status' => 'error', + 'errors' => [self::trans('validation.regex_format', [ + 'attribute' => $propertyKey, + 'format' => "a-zA-Z0-9_-" + ])] + ], 422); + } + $properties[$propertyKey] = $value; + } + } - $file = $user->fsItems()->create(['type' => Item::TYPE_INCOMPLETE | Item::TYPE_FILE]); - $file->setProperty('name', $filename); + $file = $this->deduplicateOrCreate($request, Item::TYPE_INCOMPLETE | Item::TYPE_FILE); + $file->setProperties($properties); + + if ($parentHeader = $request->headers->get('X-Kolab-Parents', null)) { + $parents = explode(',', $parentHeader); + $file->parents()->sync($parents); + } + + if ($parent = $request->input('parent')) { + $file->parents()->sync([$parent]); + } try { $response = Storage::fileInput($request->getContent(true), $params, $file); @@ -412,7 +560,7 @@ */ public function update(Request $request, $id) { - $file = $this->inputFile($id, self::WRITE); + $file = $this->inputItem($id, self::WRITE); if (is_int($file)) { return $this->errorResponse($file); @@ -425,7 +573,7 @@ // Validate file name input if ($filename != $file->getProperty('name')) { - $v = Validator::make($request->all(), ['name' => [new FileName($file->user)]]); + $v = Validator::make($request->all(), ['name' => [new FileName()]]); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); @@ -434,7 +582,21 @@ $file->setProperty('name', $filename); } - // $file->save(); + + if ($parentHeader = $request->headers->get('X-Kolab-Parents', null)) { + $parents = explode(',', $parentHeader); + $file->parents()->sync($parents); + } + if ($parentHeader = $request->headers->get('X-Kolab-Add-Parents', null)) { + $parents = explode(',', $parentHeader); + $file->parents()->syncWithoutDetaching($parents); + } + if ($parentHeader = $request->headers->get('X-Kolab-Remove-Parents', null)) { + $parents = explode(',', $parentHeader); + $file->parents()->detach($parents); + } + + $file->save(); } elseif ($media == 'resumable' || $media == 'content') { $params = []; @@ -548,7 +710,7 @@ * * @return \App\Fs\Item|int File object or error code */ - protected function inputFile($fileId, $permission) + protected function inputItem($fileId, $permission) { $user = $this->guard()->user(); $isShare = str_starts_with($fileId, 'share-'); @@ -572,7 +734,7 @@ $file = Item::find($fileId); - if (!$file || !($file->type & Item::TYPE_FILE) || ($file->type & Item::TYPE_INCOMPLETE)) { + if (!$file) { return 404; } @@ -580,6 +742,10 @@ return 403; } + if ($file->type & Item::TYPE_FILE && $file->type & Item::TYPE_INCOMPLETE) { + return 404; + } + return $file; } @@ -594,6 +760,13 @@ protected function objectToClient($object, bool $full = false): array { $result = ['id' => $object->id]; + if ($object->type & Item::TYPE_COLLECTION) { + $result['type'] = self::TYPE_COLLECTION; + } elseif ($object->type & Item::TYPE_FILE) { + $result['type'] = self::TYPE_FILE; + } else { + $result['type'] = self::TYPE_UNKNOWN; + } if ($full) { $props = array_filter($object->getProperties(['name', 'size', 'mimetype'])); diff --git a/src/app/Providers/PassportServiceProvider.php b/src/app/Providers/PassportServiceProvider.php --- a/src/app/Providers/PassportServiceProvider.php +++ b/src/app/Providers/PassportServiceProvider.php @@ -20,6 +20,7 @@ Passport::tokensCan([ 'api' => 'Access API', 'mfa' => 'Access MFA API', + 'fs' => 'Access Files API', ]); Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes'))); diff --git a/src/app/Rules/FileName.php b/src/app/Rules/FileName.php --- a/src/app/Rules/FileName.php +++ b/src/app/Rules/FileName.php @@ -3,23 +3,10 @@ namespace App\Rules; use Illuminate\Contracts\Validation\Rule; -use Illuminate\Support\Facades\Validator; -use Illuminate\Support\Str; class FileName implements Rule { private $message; - private $owner; - - /** - * Class constructor. - * - * @param \App\User $owner The file owner - */ - public function __construct($owner) - { - $this->owner = $owner; - } /** * Determine if the validation rule passes. @@ -56,18 +43,6 @@ // 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; } diff --git a/src/database/migrations/2023_03_05_100000_fs_relations_table.php b/src/database/migrations/2023_03_05_100000_fs_relations_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_03_05_100000_fs_relations_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->string('item_id', 36); + $table->string('related_id', 36); + + $table->foreign('item_id')->references('id')->on('fs_items') + ->onDelete('cascade'); + $table->foreign('related_id')->references('id')->on('fs_items') + ->onDelete('cascade'); + $table->unique(['item_id', 'related_id']); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('fs_relations'); + } +}; diff --git a/src/resources/js/files.js b/src/resources/js/files.js --- a/src/resources/js/files.js +++ b/src/resources/js/files.js @@ -94,7 +94,7 @@ // A "recursive" function to upload the file in chunks (if needed) const uploadFn = (start = 0, uploadId) => { - let url = 'api/v4/files' + let url = 'api/v4/fs' let body = '' if (file.size <= maxChunkSize) { @@ -103,15 +103,21 @@ // the file is uploading, but the risk is quite small. body = file start += maxChunkSize + if (params.parent) { + url = url + '?parent=' + params.parent + } } 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 + if (params.parent) { + config.params.parent = params.parent + } } else { // Upload a chunk of the file to the upload location - url = 'api/v4/files/uploads/' + uploadId + url = 'api/v4/fs/uploads/' + uploadId body = file.slice(start, start + maxChunkSize, file.type) config.params = { from: start } @@ -154,7 +160,7 @@ * Download a file. Starts downloading using a hidden link trick. */ this.fileDownload = (id) => { - axios.get('api/v4/files/' + id + '?downloadUrl=1') + axios.get('api/v4/fs/' + id + '?downloadUrl=1') .then(response => { // Create a dummy link element and click it if (response.data.downloadUrl) { @@ -167,7 +173,7 @@ * Rename a file. */ this.fileRename = (id, name) => { - axios.put('api/v4/files/' + id, { name }) + axios.put('api/v4/fs/' + id, { name }) .then(response => { }) 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 @@ -82,7 +82,7 @@ meta: { requiresAuth: true /*, perm: 'files' */ } }, { - path: '/files', + path: '/files/:parent?', name: 'files', component: FileListComponent, meta: { requiresAuth: true, perm: 'files' } 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 @@ -83,6 +83,10 @@ 'file-permissions-update-success' => 'File permissions updated successfully.', 'file-permissions-delete-success' => 'File permissions deleted successfully.', + 'collection-create-success' => 'Collection created successfully.', + 'collection-delete-success' => 'Collection deleted successfully.', + 'collection-update-success' => 'Collection updated successfully.', + 'payment-status-paid' => 'The payment has been completed successfully.', 'payment-status-canceled' => 'The payment has been canceled.', 'payment-status-failed' => 'The payment failed.', 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 @@ -165,6 +165,12 @@ . "to the file via a unique link.", ], + 'collection' => [ + 'create' => "Create collection", + 'new' => "New Collection", + 'name' => "Name", + ], + 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 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 @@ -106,6 +106,7 @@ 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', 'prohibits' => 'The :attribute field prohibits :other from being present.', 'regex' => 'The :attribute format is invalid.', + 'regex_format' => 'The :attribute does not match the format :format.', 'required' => 'The :attribute field is required.', 'required_array_keys' => 'The :attribute field must contain entries for: :values.', 'required_if' => 'The :attribute field is required when :other is :value.', diff --git a/src/resources/vue/File/Info.vue b/src/resources/vue/File/Info.vue --- a/src/resources/vue/File/Info.vue +++ b/src/resources/vue/File/Info.vue @@ -1,7 +1,21 @@