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,34 @@ ); } } + + /** + * Relations for this user + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function relations() + { + return $this->hasMany(Relation::class); + } + + /** + * Relations for this item + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function children() + { + return $this->belongsToMany(Item::class, 'fs_relations', 'item', 'related'); + } + + /** + * Relations for this item + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ + public function parents() + { + return $this->belongsToMany(Item::class, 'fs_relations', 'related', 'item'); + } } 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', 'related']; + + /** @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", "files"] ]); $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,7 +13,7 @@ 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'; @@ -35,7 +35,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 +45,13 @@ // storage later with the fs:expunge command $file->delete(); + if ($file->type & Item::TYPE_COLLECTION) { + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.collection-delete-success'), + ]); + } + return response()->json([ 'status' => 'success', 'message' => \trans('app.file-delete-success'), @@ -85,7 +92,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 +120,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 +181,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 +213,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 +271,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') + ->where('fs_relations.item', $parent); + } else { + $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related') + ->whereNull('fs_relations.related'); + } + + // 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 == "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 +343,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 +355,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/items/downloads/' . $downloadId); } elseif (request()->input('download')) { // Return the file content return Storage::fileDownload($file); @@ -345,6 +372,63 @@ 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) + { + $item = $this->deduplicateOrCreate($request, Item::TYPE_COLLECTION); + $item->setProperties([ + 'name' => $request->input('name'), + 'deviceId' => $request->input('deviceId'), + 'collectionType' => $request->input('collectionType'), + ]); + + foreach ($request->all() as $key => $value) { + if (str_starts_with($key, "property-")) { + $item->setProperty(substr($key, 9), $value); + } + } + + if ($parent = $request->input('parent')) { + $item->parents()->sync([$parent]); + } + + $response = []; + $response['status'] = 'success'; + $response['id'] = $item->id; + $response['message'] = \trans('app.collection-create-success'); + + return response()->json($response); + } + /** * Create a new file. * @@ -354,10 +438,13 @@ */ public function store(Request $request) { - $user = $this->guard()->user(); + $type = $request->input('type'); + if ($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 +453,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,10 +463,24 @@ } // TODO: Delete the existing incomplete file with the same name? - - $file = $user->fsItems()->create(['type' => Item::TYPE_INCOMPLETE | Item::TYPE_FILE]); + $file = $this->deduplicateOrCreate($request, Item::TYPE_FILE); $file->setProperty('name', $filename); + foreach ($request->all() as $key => $value) { + if (str_starts_with($key, "property-")) { + $file->setProperty(substr($key, 9), $value); + } + } + + 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,12 +509,26 @@ */ 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); } + + 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); + } + $media = $request->input('media') ?: 'metadata'; if ($media == 'metadata') { @@ -425,7 +536,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); @@ -548,7 +659,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 +683,7 @@ $file = Item::find($fileId); - if (!$file || !($file->type & Item::TYPE_FILE) || ($file->type & Item::TYPE_INCOMPLETE)) { + if (!$file) { return 404; } @@ -580,6 +691,10 @@ return 403; } + if ($file->type & Item::TYPE_FILE && $file->type & Item::TYPE_INCOMPLETE) { + return 404; + } + return $file; } @@ -594,6 +709,11 @@ protected function objectToClient($object, bool $full = false): array { $result = ['id' => $object->id]; + if ($object->type & ITEM::TYPE_COLLECTION) { + $result['type'] = 'collection'; + } else { + $result['type'] = 'item'; + } 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', + 'files' => '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', 36); + $table->string('related', 36); + + $table->foreign('item')->references('id')->on('fs_items') + ->onDelete('cascade'); + $table->foreign('related')->references('id')->on('fs_items') + ->onDelete('cascade'); + $table->unique(['item', 'related']); + } + ); + } + + /** + * 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/items' 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) { + url = url + '?parent=' + params.parent + } } else { // Upload a chunk of the file to the upload location - url = 'api/v4/files/uploads/' + uploadId + url = 'api/v4/items/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/items/' + 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/items/' + 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 @@ -79,6 +79,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 @@ -162,6 +162,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/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 @@