Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117911816
D4190.1775399215.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
71 KB
Referenced Files
None
Subscribers
None
D4190.1775399215.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D4190: Collection support for filesystem items
Attached
Detach File
Event Timeline