Page MenuHomePhorge

D4190.1775399215.diff
No OneTemporary

Authored By
Unknown
Size
71 KB
Referenced Files
None
Subscribers
None

D4190.1775399215.diff

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<int, string> 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 @@
+<?php
+
+namespace App\Fs;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a filesystem relation.
+ *
+ * @property string $id Relation identifier
+ * @property string $item_id Item identifier
+ * @property string $related_id Related item identifier
+ */
+class Relation extends Model
+{
+ /** @var array<int, string> 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,83 @@
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 +462,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 +477,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 +487,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 +551,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 +564,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 +573,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 +701,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 +725,7 @@
$file = Item::find($fileId);
- if (!$file || !($file->type & Item::TYPE_FILE) || ($file->type & Item::TYPE_INCOMPLETE)) {
+ if (!$file) {
return 404;
}
@@ -580,6 +733,10 @@
return 403;
}
+ if ($file->type & Item::TYPE_FILE && $file->type & Item::TYPE_INCOMPLETE) {
+ return 404;
+ }
+
return $file;
}
@@ -594,6 +751,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 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'fs_relations',
+ function (Blueprint $table) {
+ $table->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 @@
<template>
<div class="container">
<div class="card" id="file-info">
- <div class="card-body">
+ <div class="card-body" v-if="fileId === 'newCollection'">
+ <div class="card-title" >{{ $t('collection.new') }}</div>
+ <div class="card-text">
+ <form @submit.prevent="submit" class="card-body">
+ <div class="row mb-3">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('collection.name') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="name" v-model="file.name" :disabled="file.id">
+ </div>
+ </div>
+ <btn v-if="!file.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </div>
+ </div>
+ <div class="card-body" v-else>
<div class="card-title">
{{ file.name }}
<btn v-if="file.canDelete" class="btn-outline-danger button-delete float-end" @click="fileDelete" icon="trash-can">{{ $t('file.delete') }}</btn>
@@ -83,6 +97,7 @@
return {
file: {},
fileId: null,
+ collectionId: null,
shares: []
}
},
@@ -90,28 +105,31 @@
this.api = new FileAPI({})
this.fileId = this.$route.params.file
+ this.collectionId = this.$route.query.parent
- axios.get('/api/v4/files/' + this.fileId, { loader: true })
- .then(response => {
- this.file = response.data
+ if (this.fileId != 'newCollection') {
+ axios.get('/api/v4/fs/' + this.fileId, { loader: true })
+ .then(response => {
+ this.file = response.data
- if (this.file.isOwner) {
- axios.get('api/v4/files/' + this.fileId + '/permissions')
- .then(response => {
- if (response.data.list) {
- this.shares = response.data.list
- }
- })
- }
- })
- .catch(this.$root.errorHandler)
+ if (this.file.isOwner) {
+ axios.get('api/v4/fs/' + this.fileId + '/permissions')
+ .then(response => {
+ if (response.data.list) {
+ this.shares = response.data.list
+ }
+ })
+ }
+ })
+ .catch(this.$root.errorHandler)
+ }
},
methods: {
copyLink(link) {
navigator.clipboard.writeText(link);
},
fileDelete() {
- axios.delete('api/v4/files/' + this.fileId)
+ axios.delete('api/v4/fs/' + this.fileId)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
@@ -129,7 +147,7 @@
return
}
- axios.post('api/v4/files/' + this.fileId + '/permissions', post)
+ axios.post('api/v4/fs/' + this.fileId + '/permissions', post)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
@@ -138,13 +156,26 @@
})
},
shareDelete(id) {
- axios.delete('api/v4/files/' + this.fileId + '/permissions/' + id)
+ axios.delete('api/v4/fs/' + this.fileId + '/permissions/' + id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$delete(this.shares, this.shares.findIndex(element => element.id == id))
}
})
+ },
+ submit() {
+ this.$root.clearFormValidation($('#general form'))
+
+ let post = this.$root.pick(this.file, ['name'])
+ axios.post('/api/v4/fs', post, { params: {
+ type: 'collection',
+ parent: this.collectionId
+ }})
+ .then(response => {
+ this.$toast.success(response.data.message)
+ this.$router.replace({ name: 'files', params: { parent: this.collectionId }})
+ })
}
}
}
diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue
--- a/src/resources/vue/File/List.vue
+++ b/src/resources/vue/File/List.vue
@@ -2,12 +2,25 @@
<div class="container">
<div class="card" id="files">
<div class="card-body">
- <div class="card-title">
+ <div class="card-title" v-if="collectionId">
+ {{ $t('dashboard.files') + ' - ' + collection.name }}
+ <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
+ <div id="drop-area" class="file-drop-area float-end">
+ <svg-icon icon="upload"></svg-icon> Click or drop file(s) here
+ </div>
+ <btn-router v-if="!$root.isDegraded()" class="float-end" :to="`/file/newCollection?parent=${collectionId}`" icon="folder">
+ {{ $t('collection.create') }}
+ </btn-router>
+ </div>
+ <div class="card-title" v-else>
{{ $t('dashboard.files') }}
<small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
<div id="drop-area" class="file-drop-area float-end">
<svg-icon icon="upload"></svg-icon> Click or drop file(s) here
</div>
+ <btn-router v-if="!$root.isDegraded()" class="float-end" to="/file/newCollection" icon="folder">
+ {{ $t('collection.create') }}
+ </btn-router>
</div>
<div class="card-text pt-4">
<div class="mb-2 d-flex w-100">
@@ -22,11 +35,18 @@
</thead>
<tbody>
<tr v-for="file in files" :key="file.id" @click="$root.clickRecord">
- <td class="name">
+ <td class="name" v-if="file.type === 'collection'">
+ <svg-icon icon="folder"></svg-icon>
+ <router-link :to="{ path: '/file/' + file.id }">{{ file.name }}</router-link>
+ </td>
+ <td class="name" v-else>
<svg-icon icon="file"></svg-icon>
- <router-link :to="{ path: 'file/' + file.id }">{{ file.name }}</router-link>
+ <router-link :to="{ path: '/file/' + file.id }">{{ file.name }}</router-link>
+ </td>
+ <td class="buttons" v-if="file.type === 'collection'">
+ <btn class="button-delete text-danger p-0 ms-1" @click="fileDelete(file)" icon="trash-can" :title="$t('btn.delete')"></btn>
</td>
- <td class="buttons">
+ <td class="buttons" v-else>
<btn class="button-download p-0 ms-1" @click="fileDownload(file)" icon="download" :title="$t('btn.download')"></btn>
<btn class="button-delete text-danger p-0 ms-1" @click="fileDelete(file)" icon="trash-can" :title="$t('btn.delete')"></btn>
</td>
@@ -49,24 +69,47 @@
library.add(
require('@fortawesome/free-solid-svg-icons/faFile').definition,
+ require('@fortawesome/free-solid-svg-icons/faFolder').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
require('@fortawesome/free-solid-svg-icons/faUpload').definition,
)
export default {
mixins: [ ListTools ],
+ beforeRouteUpdate (to, from, next) {
+ this.collectionId = this.$route.params.parent
+ // An event called when the route that renders this component has changed,
+ // but this component is reused in the new route.
+ // Required to handle links from /file/XXX to /file/YYY
+ next()
+ this.$parent.routerReload()
+ },
data() {
return {
api: {},
- files: []
+ collection: {},
+ files: [],
+ collectionId: null
+ }
+ },
+ created() {
+ this.collectionId = this.$route.params.parent
+ if (this.collectionId) {
+ axios.get('/api/v4/fs/' + this.collectionId, { loader: true })
+ .then(response => {
+ this.collection = response.data
+ })
+ .catch(this.$root.errorHandler)
}
},
mounted() {
this.uploads = {}
+ this.collectionId = this.$route.params.parent
this.api = new FileAPI({
dropArea: '#drop-area',
- eventHandler: this.eventHandler
+ eventHandler: this.eventHandler,
+ parent: this.collectionId
})
this.loadFiles({ init: true })
@@ -81,7 +124,7 @@
}
},
fileDelete(file) {
- axios.delete('api/v4/files/' + file.id)
+ axios.delete('api/v4/fs/' + file.id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
@@ -93,14 +136,17 @@
fileDownload(file) {
// This is not an appropriate method for big files, we can consider
// using it still for very small files.
- // downloadFile('api/v4/files/' + file.id + '?download=1', file.name)
+ // downloadFile('api/v4/fs/' + file.id + '?download=1', file.name)
// This method first makes a request to the API to get the download URL (which does not
// require authentication) and then use it to download the file.
this.api.fileDownload(file.id)
},
loadFiles(params) {
- this.listSearch('files', 'api/v4/files', params)
+ if (this.collectionId) {
+ params['parent'] = this.collectionId
+ }
+ this.listSearch('files', 'api/v4/fs', params)
},
searchFiles(search) {
this.loadFiles({ reset: true, search })
diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue
--- a/src/resources/vue/Widgets/ListTools.vue
+++ b/src/resources/vue/Widgets/ListTools.vue
@@ -99,6 +99,7 @@
data() {
return {
currentSearch: '',
+ currentParent: '',
hasMore: false,
page: 1
}
@@ -124,6 +125,14 @@
get.search = this.currentSearch
}
+
+ if ('parent' in params) {
+ get.parent = params.parent
+ this.currentParent = params.parent
+ } else {
+ get.parent = this.currentParent
+ }
+
if (!params.init) {
loader = $(this.$el).find('.more-loader')
if (!loader.length || get.page == 1) {
@@ -134,6 +143,7 @@
}
} else {
this.currentSearch = null
+ this.currentParent = null
}
axios.get(url, { params: get, loader })
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -86,6 +86,35 @@
}
);
+if (\config('app.with_files')) {
+ Route::group(
+ [
+ 'domain' => \config('app.website_domain'),
+ 'middleware' => ['auth:api', 'scope:fs,api'],
+ 'prefix' => 'v4'
+ ],
+ function () {
+ Route::apiResource('fs', API\V4\FsController::class);
+ Route::get('fs/{itemId}/permissions', [API\V4\FsController::class, 'getPermissions']);
+ Route::post('fs/{itemId}/permissions', [API\V4\FsController::class, 'createPermission']);
+ Route::put('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'updatePermission']);
+ Route::delete('fs/{itemId}/permissions/{id}', [API\V4\FsController::class, 'deletePermission']);
+ }
+ );
+ Route::group(
+ [
+ 'domain' => \config('app.website_domain'),
+ 'middleware' => [],
+ 'prefix' => 'v4'
+ ],
+ function () {
+ Route::post('fs/uploads/{id}', [API\V4\FsController::class, 'upload'])
+ ->middleware(['api']);
+ Route::get('fs/downloads/{id}', [API\V4\FsController::class, 'download']);
+ }
+ );
+}
+
Route::group(
[
'domain' => \config('app.website_domain'),
@@ -104,19 +133,6 @@
Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
- if (\config('app.with_files')) {
- 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', 'scope:api'])
- ->middleware(['api']);
- Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download'])
- ->withoutMiddleware(['auth:api', 'scope:api']);
- }
-
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
diff --git a/src/tests/Feature/Controller/FilesTest.php b/src/tests/Feature/Controller/FsTest.php
rename from src/tests/Feature/Controller/FilesTest.php
rename to src/tests/Feature/Controller/FsTest.php
--- a/src/tests/Feature/Controller/FilesTest.php
+++ b/src/tests/Feature/Controller/FsTest.php
@@ -6,13 +6,14 @@
use App\Fs\Item;
use App\Fs\Property;
use App\User;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage as LaravelStorage;
use Tests\TestCase;
/**
* @group files
*/
-class FilesTest extends TestCase
+class FsTest extends TestCase
{
/**
* {@inheritDoc}
@@ -31,7 +32,7 @@
{
Item::query()->delete();
- $disk = LaravelStorage::disk('files');
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
foreach ($disk->listContents('') as $dir) {
$disk->deleteDirectory($dir->path());
}
@@ -40,7 +41,7 @@
}
/**
- * Test deleting files (DELETE /api/v4/files/<file-id>)
+ * Test deleting items (DELETE /api/v4/fs/<item-id>)
*/
public function testDelete(): void
{
@@ -49,19 +50,19 @@
$file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']);
// Unauth access
- $response = $this->delete("api/v4/files/{$file->id}");
+ $response = $this->delete("api/v4/fs/{$file->id}");
$response->assertStatus(401);
// Unauth access
- $response = $this->actingAs($jack)->delete("api/v4/files/{$file->id}");
+ $response = $this->actingAs($jack)->delete("api/v4/fs/{$file->id}");
$response->assertStatus(403);
// Non-existing file
- $response = $this->actingAs($john)->delete("api/v4/files/123");
+ $response = $this->actingAs($john)->delete("api/v4/fs/123");
$response->assertStatus(404);
// File owner access
- $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}");
+ $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}");
$response->assertStatus(200);
$json = $response->json();
@@ -76,7 +77,7 @@
}
/**
- * Test file downloads (GET /api/v4/files/downloads/<id>)
+ * Test file downloads (GET /api/v4/fs/downloads/<id>)
*/
public function testDownload(): void
{
@@ -85,18 +86,18 @@
$file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']);
// Unauth access
- $response = $this->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response = $this->get("api/v4/fs/{$file->id}?downloadUrl=1");
$response->assertStatus(401);
- $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}?downloadUrl=1");
$response->assertStatus(403);
// Non-existing file
- $response = $this->actingAs($john)->get("api/v4/files/123456?downloadUrl=1");
+ $response = $this->actingAs($john)->get("api/v4/fs/123456?downloadUrl=1");
$response->assertStatus(404);
// Get downloadLink for the file
- $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}?downloadUrl=1");
$response->assertStatus(200);
$json = $response->json();
@@ -108,14 +109,13 @@
$response = $this->get(substr($link, strpos($link, '/api/') + 1));
$response->assertStatus(200)
->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
- // ->assertHeader('Content-Length', $file->getProperty('size'))
->assertHeader('Content-Type', $file->getProperty('mimetype'));
$this->assertSame('Teśt content', $response->streamedContent());
// Test acting as another user with read permission
$permission = $this->getTestFilePermission($file, $jack, 'r');
- $response = $this->actingAs($jack)->get("api/v4/files/{$permission->key}?downloadUrl=1");
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$permission->key}?downloadUrl=1");
$response->assertStatus(200);
$json = $response->json();
@@ -127,14 +127,13 @@
$response = $this->get(substr($link, strpos($link, '/api/') + 1));
$response->assertStatus(200)
->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
- // ->assertHeader('Content-Length', $file->getProperty('size'))
->assertHeader('Content-Type', $file->getProperty('mimetype'));
$this->assertSame('Teśt content', $response->streamedContent());
// Test downloading a multi-chunk file
$file = $this->getTestFile($john, 'test2.txt', ['T1', 'T2'], ['mimetype' => 'plain/text']);
- $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}?downloadUrl=1");
$response->assertStatus(200);
$json = $response->json();
@@ -146,14 +145,13 @@
$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/<file-id>/permissions)
+ * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/fs/<file-id>/permissions)
*/
public function testPermissions(): void
{
@@ -162,25 +160,25 @@
$file = $this->getTestFile($john, 'test1.txt', []);
// Unauth access not allowed
- $response = $this->get("api/v4/files/{$file->id}/permissions");
+ $response = $this->get("api/v4/fs/{$file->id}/permissions");
$response->assertStatus(401);
- $response = $this->post("api/v4/files/{$file->id}/permissions", []);
+ $response = $this->post("api/v4/fs/{$file->id}/permissions", []);
$response->assertStatus(401);
// Non-existing file
- $response = $this->actingAs($john)->get("api/v4/files/1234/permissions");
+ $response = $this->actingAs($john)->get("api/v4/fs/1234/permissions");
$response->assertStatus(404);
- $response = $this->actingAs($john)->post("api/v4/files/1234/permissions", []);
+ $response = $this->actingAs($john)->post("api/v4/fs/1234/permissions", []);
$response->assertStatus(404);
// No permissions to the file
- $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}/permissions");
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}/permissions");
$response->assertStatus(403);
- $response = $this->actingAs($jack)->post("api/v4/files/{$file->id}/permissions", []);
+ $response = $this->actingAs($jack)->post("api/v4/fs/{$file->id}/permissions", []);
$response->assertStatus(403);
// Expect an empty list of permissions
- $response = $this->actingAs($john)->get("api/v4/files/{$file->id}/permissions");
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}/permissions");
$response->assertStatus(200);
$json = $response->json();
@@ -190,7 +188,7 @@
$this->assertSame(0, $json['count']);
// Empty input
- $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", []);
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", []);
$response->assertStatus(422);
$json = $response->json();
@@ -202,7 +200,7 @@
// Test more input validation
$post = ['user' => 'user', 'permissions' => 'read'];
- $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post);
+ $response = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -214,7 +212,7 @@
// 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 = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -232,7 +230,7 @@
// 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 = $this->actingAs($john)->post("api/v4/fs/{$file->id}/permissions", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -241,11 +239,11 @@
$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 = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/1234", $post);
$response->assertStatus(404);
$post = ['user' => 'jack@kolab.org', 'permissions' => 'read-write'];
- $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/{$permission->key}", $post);
+ $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/{$permission->key}", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -263,7 +261,7 @@
// 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 = $this->actingAs($john)->put("api/v4/fs/{$file->id}/permissions/{$permission->key}", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -272,7 +270,7 @@
$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 = $this->actingAs($john)->get("api/v4/fs/{$file->id}/permissions");
$response->assertStatus(200);
$json = $response->json();
@@ -287,10 +285,10 @@
$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 = $this->actingAs($john)->delete("api/v4/fs/{$file->id}/permissions/1234");
$response->assertStatus(404);
- $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}/permissions/{$permission->key}");
+ $response = $this->actingAs($john)->delete("api/v4/fs/{$file->id}/permissions/{$permission->key}");
$response->assertStatus(200);
$json = $response->json();
@@ -302,18 +300,18 @@
}
/**
- * Test fetching files/folders list (GET /api/v4/files)
+ * Test fetching file/folders list (GET /api/v4/fs)
*/
public function testIndex(): void
{
// Unauth access not allowed
- $response = $this->get("api/v4/files");
+ $response = $this->get("api/v4/fs");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
// Expect an empty list
- $response = $this->actingAs($user)->get("api/v4/files");
+ $response = $this->actingAs($user)->get("api/v4/fs");
$response->assertStatus(200);
$json = $response->json();
@@ -327,7 +325,7 @@
$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 = $this->actingAs($user)->get("api/v4/fs");
$response->assertStatus(200);
$json = $response->json();
@@ -342,7 +340,7 @@
$this->assertSame($file2->id, $json['list'][1]['id']);
// Searching
- $response = $this->actingAs($user)->get("api/v4/files?search=t2");
+ $response = $this->actingAs($user)->get("api/v4/fs?search=t2");
$response->assertStatus(200);
$json = $response->json();
@@ -360,7 +358,7 @@
$file1->type |= Item::TYPE_INCOMPLETE;
$file1->save();
- $response = $this->actingAs($user)->get("api/v4/files");
+ $response = $this->actingAs($user)->get("api/v4/fs");
$response->assertStatus(200);
$json = $response->json();
@@ -370,12 +368,12 @@
}
/**
- * Test fetching file metadata (GET /api/v4/files/<file-id>)
+ * Test fetching file metadata (GET /api/v4/fs/<file-id>)
*/
public function testShow(): void
{
// Unauth access not allowed
- $response = $this->get("api/v4/files/1234");
+ $response = $this->get("api/v4/fs/1234");
$response->assertStatus(401);
$john = $this->getTestUser('john@kolab.org');
@@ -383,15 +381,15 @@
$file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']);
// Non-existing file
- $response = $this->actingAs($jack)->get("api/v4/files/1234");
+ $response = $this->actingAs($jack)->get("api/v4/fs/1234");
$response->assertStatus(404);
// Unauthorized access
- $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}");
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$file->id}");
$response->assertStatus(403);
// Get file metadata
- $response = $this->actingAs($john)->get("api/v4/files/{$file->id}");
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}");
$response->assertStatus(200);
$json = $response->json();
@@ -405,17 +403,16 @@
$this->assertSame(true, $json['canDelete']);
// Get file content
- $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?download=1");
+ $response = $this->actingAs($john)->get("api/v4/fs/{$file->id}?download=1");
$response->assertStatus(200)
->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
- // ->assertHeader('Content-Length', $file->getProperty('size'))
->assertHeader('Content-Type', $file->getProperty('mimetype'));
$this->assertSame('Teśt content', $response->streamedContent());
// Test acting as a user with file permissions
$permission = $this->getTestFilePermission($file, $jack, 'r');
- $response = $this->actingAs($jack)->get("api/v4/files/{$permission->key}");
+ $response = $this->actingAs($jack)->get("api/v4/fs/{$permission->key}");
$response->assertStatus(200);
$json = $response->json();
@@ -427,18 +424,18 @@
}
/**
- * Test creating files (POST /api/v4/files)
+ * Test creating files (POST /api/v4/fs)
*/
public function testStore(): void
{
// Unauth access not allowed
- $response = $this->post("api/v4/files");
+ $response = $this->post("api/v4/fs");
$response->assertStatus(401);
$john = $this->getTestUser('john@kolab.org');
// Test input validation
- $response = $this->sendRawBody($john, 'POST', "api/v4/files", [], '');
+ $response = $this->sendRawBody($john, 'POST', "api/v4/fs", [], '');
$response->assertStatus(422);
$json = $response->json();
@@ -446,7 +443,7 @@
$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 = $this->sendRawBody($john, 'POST', "api/v4/fs?name=*.txt", [], '');
$response->assertStatus(422);
$json = $response->json();
@@ -457,7 +454,7 @@
// Create a file - the simple method
$body = "test content";
$headers = [];
- $response = $this->sendRawBody($john, 'POST', "api/v4/files?name=test.txt", $headers, $body);
+ $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt", $headers, $body);
$response->assertStatus(200);
$json = $response->json();
@@ -478,13 +475,13 @@
}
/**
- * Test creating files - resumable (POST /api/v4/files)
+ * Test creating files - resumable (POST /api/v4/fs)
*/
public function testStoreResumable(): void
{
$john = $this->getTestUser('john@kolab.org');
- $response = $this->actingAs($john)->post("api/v4/files?name=test2.txt&media=resumable&size=400");
+ $response = $this->actingAs($john)->post("api/v4/fs?name=test2.txt&media=resumable&size=400");
$response->assertStatus(200);
$json = $response->json();
@@ -495,13 +492,17 @@
$json['uploadId']
);
+ $upload = Cache::get('upload:' . $json['uploadId']);
+ $file = Item::find($upload['fileId']);
+ $this->assertSame(Item::TYPE_INCOMPLETE | Item::TYPE_FILE, $file->type);
+
$uploadId = $json['uploadId'];
$size = 0;
$fileContent = '';
for ($x = 0; $x <= 2; $x++) {
$body = str_repeat("$x", 100);
- $response = $this->sendRawBody(null, 'POST', "api/v4/files/uploads/{$uploadId}?from={$size}", [], $body);
+ $response = $this->sendRawBody(null, 'POST', "api/v4/fs/uploads/{$uploadId}?from={$size}", [], $body);
$response->assertStatus(200);
$json = $response->json();
@@ -513,7 +514,7 @@
}
$body = str_repeat("$x", 100);
- $response = $this->sendRawBody(null, 'POST', "api/v4/files/uploads/{$uploadId}?from={$size}", [], $body);
+ $response = $this->sendRawBody(null, 'POST', "api/v4/fs/uploads/{$uploadId}?from={$size}", [], $body);
$response->assertStatus(200);
$json = $response->json();
@@ -536,12 +537,12 @@
}
/**
- * Test updating files (PUT /api/v4/files/<file-id>)
+ * Test updating files (PUT /api/v4/fs/<file-id>)
*/
public function testUpdate(): void
{
// Unauth access not allowed
- $response = $this->put("api/v4/files/1234");
+ $response = $this->put("api/v4/fs/1234");
$response->assertStatus(401);
$john = $this->getTestUser('john@kolab.org');
@@ -549,16 +550,16 @@
$file = $this->getTestFile($john, 'teśt.txt', 'Teśt content', ['mimetype' => 'plain/text']);
// Non-existing file
- $response = $this->actingAs($john)->put("api/v4/files/1234", []);
+ $response = $this->actingAs($john)->put("api/v4/fs/1234", []);
$response->assertStatus(404);
// Unauthorized access
- $response = $this->actingAs($jack)->put("api/v4/files/{$file->id}", []);
+ $response = $this->actingAs($jack)->put("api/v4/fs/{$file->id}", []);
$response->assertStatus(403);
// Test name validation
$post = ['name' => 'test/test.txt'];
- $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post);
+ $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -567,7 +568,7 @@
$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 = $this->actingAs($john)->put("api/v4/fs/{$file->id}", $post);
$response->assertStatus(422);
$json = $response->json();
@@ -577,7 +578,7 @@
// Rename a file
$post = ['name' => 'new namś.txt'];
- $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post);
+ $response = $this->actingAs($john)->put("api/v4/fs/{$file->id}", $post);
$response->assertStatus(200);
$json = $response->json();
@@ -591,7 +592,7 @@
// Update file content
$body = "Test1\nTest2";
- $response = $this->sendRawBody($john, 'PUT', "api/v4/files/{$file->id}?media=content", [], $body);
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}?media=content", [], $body);
$response->assertStatus(200);
$json = $response->json();
@@ -621,7 +622,7 @@
*/
protected function getTestFile(User $user, string $name, $content = [], $props = []): Item
{
- $disk = LaravelStorage::disk('files');
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
$file = $user->fsItems()->create(['type' => Item::TYPE_FILE]);
$size = 0;
@@ -656,6 +657,28 @@
return $file;
}
+ /**
+ * Create a test collection.
+ *
+ * @param \App\User $user File owner
+ * @param string $name File name
+ * @param array $props Extra collection properties
+ *
+ * @return \App\Fs\Item
+ */
+ protected function getTestCollection(User $user, string $name, $props = []): Item
+ {
+ $collection = $user->fsItems()->create(['type' => Item::TYPE_COLLECTION]);
+
+ $properties = [
+ 'name' => $name,
+ ];
+
+ $collection->setProperties($props + $properties);
+
+ return $collection;
+ }
+
/**
* Get contents of a test file.
*
@@ -668,7 +691,7 @@
$content = '';
$file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file, &$content) {
- $disk = LaravelStorage::disk('files');
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
$path = Storage::chunkLocation($chunk->chunk_id, $file);
$content .= $disk->read($path);
@@ -721,4 +744,237 @@
return $this->call($method, $uri, [], $cookies, [], $server, $content);
}
}
+
+
+ /**
+ * Test creating collections (POST /api/v4/fs?type=collection)
+ */
+ public function testStoreCollection(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $params = [
+ 'name' => "MyTestCollection",
+ 'deviceId' => "myDeviceId",
+ ];
+ $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+
+ $collection = Item::find($json['id']);
+
+ $this->assertSame(Item::TYPE_COLLECTION, $collection->type);
+ $this->assertSame($params['name'], $collection->getProperty('name'));
+ $this->assertSame($params['deviceId'], $collection->getProperty('deviceId'));
+ }
+
+ /**
+ * Test creating collections (POST /api/v4/fs?type=collection)
+ */
+ public function testStoreCollectionMetadata(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $params = [
+ 'name' => "MyTestCollection",
+ 'deviceId' => "myDeviceId",
+ 'collectionType' => "photoalbum",
+ 'deduplicate-property' => "localId",
+ 'deduplicate-value' => "myDeviceId:localId",
+ 'property-localId' => "myDeviceId:localId",
+ ];
+ $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+
+ $collection = Item::find($json['id']);
+
+ $this->assertSame(Item::TYPE_COLLECTION, $collection->type);
+ $this->assertSame($params['name'], $collection->getProperty('name'));
+ $this->assertSame($params['deviceId'], $collection->getProperty('deviceId'));
+ $this->assertSame($params['collectionType'], $collection->getProperty('collectionType'));
+ $this->assertSame($params['property-localId'], $collection->getProperty('localId'));
+
+ // Deduplicate but update the name and parent
+ $parent = $this->getTestCollection($john, 'Parent');
+ $params = [
+ 'name' => "MyTestCollection2",
+ 'deviceId' => "myDeviceId",
+ 'parent' => $parent->id,
+ 'collectionType' => "photoalbum",
+ 'deduplicate-property' => "localId",
+ 'deduplicate-value' => "myDeviceId:localId",
+ ];
+ $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame($collection->id, $json['id']);
+ $this->assertSame($params['name'], $collection->getProperty('name'));
+
+
+ // Deduplicate again, but without changes
+ $parent = $this->getTestCollection($john, 'Parent');
+ $response = $this->actingAs($john)->post("api/v4/fs?type=collection", $params);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $this->assertSame('success', $json['status']);
+ }
+
+ /**
+ * Test deleting collections (DELETE /api/v4/fs/<collection-id>)
+ */
+ public function testDeleteCollection(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $collection = $this->getTestCollection($john, 'Teśt content');
+
+ // File owner access
+ $response = $this->actingAs($john)->delete("api/v4/fs/{$collection->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Collection deleted successfully.", $json['message']);
+ $this->assertSame(null, Item::find($collection->id));
+ }
+
+ /**
+ * Test store item relations (POST /api/v4/fs)
+ */
+ public function testStoreRelation(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $collection = $this->getTestCollection($john, 'My Test Collection');
+
+ $body = "test content";
+ $headers = ["X-Kolab-Parents" => implode(',', [$collection->id])];
+ $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt", $headers, $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $newItem = Item::find($json['id']);
+ $this->assertNotNull($newItem);
+ $this->assertSame(1, $newItem->parents()->count());
+ $this->assertSame($collection->id, $newItem->parents()->first()->id);
+
+
+ $collection2 = $this->getTestCollection($john, 'My Test Collection2');
+ $headers = ["X-Kolab-Parents" => implode(',', [$collection->id, $collection2->id])];
+ $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test2.txt", $headers, $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $newItem = Item::find($json['id']);
+ $this->assertNotNull($newItem);
+ $this->assertSame(2, $newItem->parents()->count());
+ }
+
+ /**
+ * Test store item relations (POST /api/v4/fs)
+ */
+ public function testStoreRelationParameter(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $collection = $this->getTestCollection($john, 'My Test Collection');
+
+ $body = "test content";
+ $response = $this->sendRawBody($john, 'POST', "api/v4/fs?name=test.txt&parent={$collection->id}", [], $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $newItem = Item::find($json['id']);
+ $this->assertNotNull($newItem);
+ $this->assertSame(1, $newItem->parents()->count());
+ $this->assertSame($collection->id, $newItem->parents()->first()->id);
+ }
+
+ /**
+ * Test update item relations (PUT /api/v4/fs/$itemid)
+ * Add/Remove/Set
+ */
+ public function testUpdateRelation(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $file = $this->getTestFile($john, 'test1.txt', 'Teśt content1', ['mimetype' => 'plain/text']);
+ $collection1 = $this->getTestCollection($john, 'My Test Collection');
+ $collection2 = $this->getTestCollection($john, 'My Test Collection2');
+
+ // Add parents
+ $headers = ["X-Kolab-Add-Parents" => implode(',', [$collection1->id])];
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}", $headers, '');
+ $response->assertStatus(200);
+ $this->assertSame('success', $response->json()['status']);
+
+ $parents = $file->parents()->get();
+ $this->assertSame(1, count($parents));
+ $this->assertSame($collection1->id, $parents->first()->id);
+
+ // Set parents
+ $headers = ["X-Kolab-Parents" => implode(',', [$collection1->id, $collection2->id])];
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}", $headers, '');
+ $response->assertStatus(200);
+ $this->assertSame('success', $response->json()['status']);
+
+ $parents = $file->parents()->get();
+ $this->assertSame(2, count($parents));
+
+ // Remove parents
+ $headers = ["X-Kolab-Remove-Parents" => implode(',', [$collection1->id])];
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/fs/{$file->id}", $headers, '');
+ $response->assertStatus(200);
+ $this->assertSame('success', $response->json()['status']);
+
+ $parents = $file->parents()->get();
+ $this->assertSame(1, count($parents));
+ $this->assertSame($collection2->id, $parents->first()->id);
+ }
+
+ public function testListChildren(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $file1 = $this->getTestFile($john, 'test1.txt', 'Teśt content1', ['mimetype' => 'plain/text']);
+ $file2 = $this->getTestFile($john, 'test2.txt', 'Teśt content2', ['mimetype' => 'plain/text']);
+ $collection = $this->getTestCollection($john, 'My Test Collection');
+ $collection->children()->attach($file1);
+
+ // List files in collection
+ $response = $this->actingAs($john)->get("api/v4/fs?parent={$collection->id}");
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $list = $json['list'];
+ $this->assertSame(1, count($list));
+ $this->assertSame($file1->id, $list[0]['id']);
+
+ // List files not in a collection
+ $response = $this->actingAs($john)->get("api/v4/fs?type=file");
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $list = $json['list'];
+ $this->assertSame(1, count($list));
+ $this->assertSame($file2->id, $list[0]['id']);
+
+ // Remove from collection
+ $collection->children()->detach($file1);
+
+ $response = $this->actingAs($john)->get("api/v4/fs?parent={$collection->id}");
+ $response->assertStatus(200);
+ $json = $response->json();
+ $this->assertSame(0, count($response->json()['list']));
+ }
}

File Metadata

Mime Type
text/plain
Expires
Sun, Apr 5, 2:26 PM (1 h, 16 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833643
Default Alt Text
D4190.1775399215.diff (71 KB)

Event Timeline