Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117823233
D3463.1775297224.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
111 KB
Referenced Files
None
Subscribers
None
D3463.1775297224.diff
View Options
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -108,6 +108,7 @@
REDIS_PORT=6379
OCTANE_HTTP_HOST=127.0.0.1
+SWOOLE_PACKAGE_MAX_LENGTH=10485760
PAYMENT_PROVIDER=
MOLLIE_KEY=
diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/Storage.php
@@ -0,0 +1,268 @@
+<?php
+
+namespace App\Backends;
+
+use App\Fs\Chunk;
+use App\Fs\Item;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Storage as LaravelStorage;
+use Illuminate\Support\Str;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class Storage
+{
+ /** @const How long the resumable upload "token" is valid (in seconds) */
+ public const UPLOAD_TTL = 60 * 60 * 6;
+
+
+ /**
+ * Delete a file.
+ *
+ * @param \App\Fs\Item $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileDelete(Item $file): void
+ {
+ $disk = LaravelStorage::disk('files');
+
+ $path = $file->path . '/' . $file->id;
+
+ // TODO: Deleting files might be slow, consider marking as deleted and async job
+
+ $disk->deleteDirectory($path);
+
+ $file->chunks()->delete();
+ }
+
+ /**
+ * File download handler.
+ *
+ * @param \App\Fs\Item $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileDownload(Item $file): StreamedResponse
+ {
+ $response = new StreamedResponse();
+
+ $props = $file->getProperties(['name', 'size', 'mimetype']);
+
+ // Prepare the file name for the Content-Disposition header
+ $extension = pathinfo($props['name'], \PATHINFO_EXTENSION) ?: 'file';
+ $fallbackName = str_replace('%', '', Str::ascii($props['name'])) ?: "file.{$extension}";
+ $disposition = $response->headers->makeDisposition('attachment', $props['name'], $fallbackName);
+
+ $response->headers->replace([
+ 'Content-Length' => $props['size'] ?: 0,
+ 'Content-Type' => $props['mimetype'],
+ 'Content-Disposition' => $disposition,
+ ]);
+
+ $response->setCallback(function () use ($file) {
+ $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file) {
+ $disk = LaravelStorage::disk('files');
+ $path = Storage::chunkLocation($chunk->chunk_id, $file);
+
+ $stream = $disk->readStream($path);
+
+ fpassthru($stream);
+ fclose($stream);
+ });
+ });
+
+ return $response;
+ }
+
+ /**
+ * File upload handler
+ *
+ * @param resource $stream File input stream
+ * @param array $params Request parameters
+ * @param ?\App\Fs\Item $file The file object
+ *
+ * @return array File/Response attributes
+ * @throws \Exception
+ */
+ public static function fileInput($stream, array $params, Item $file = null): array
+ {
+ if (!empty($params['uploadId'])) {
+ return self::fileInputResumable($stream, $params, $file);
+ }
+
+ $disk = LaravelStorage::disk('files');
+
+ $chunkId = \App\Utils::uuidStr();
+
+ $path = self::chunkLocation($chunkId, $file);
+
+ $disk->writeStream($path, $stream);
+
+ $fileSize = $disk->fileSize($path);
+
+ // Update the file type and size information
+ $file->setProperties([
+ 'size' => $fileSize,
+ 'mimetype' => self::mimetype($path),
+ ]);
+
+ // Assign the node to the file, "unlink" any old nodes of this file
+ $file->chunks()->delete();
+ $file->chunks()->create([
+ 'chunk_id' => $chunkId,
+ 'sequence' => 0,
+ 'size' => $fileSize,
+ ]);
+
+ return ['id' => $file->id];
+ }
+
+ /**
+ * Resumable file upload handler
+ *
+ * @param resource $stream File input stream
+ * @param array $params Request parameters
+ * @param ?\App\Fs\Item $file The file object
+ *
+ * @return array File/Response attributes
+ * @throws \Exception
+ */
+ protected static function fileInputResumable($stream, array $params, Item $file = null): array
+ {
+ // Initial request, save file metadata, return uploadId
+ if ($params['uploadId'] == 'resumable') {
+ if (empty($params['size']) || empty($file)) {
+ throw new \Exception("Missing parameters of resumable file upload.");
+ }
+
+ $params['uploadId'] = \App\Utils::uuidStr();
+
+ $upload = [
+ 'fileId' => $file->id,
+ 'size' => $params['size'],
+ 'uploaded' => 0,
+ ];
+
+ if (!Cache::add('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL)) {
+ throw new \Exception("Failed to create cache entry for resumable file upload.");
+ }
+
+ return [
+ 'uploadId' => $params['uploadId'],
+ 'uploaded' => 0,
+ 'maxChunkSize' => (\config('octane.swoole.options.package_max_length') ?: 10 * 1024 * 1024) - 8192,
+ ];
+ }
+
+ $upload = Cache::get('upload:' . $params['uploadId']);
+
+ if (empty($upload)) {
+ throw new \Exception("Cache entry for resumable file upload does not exist.");
+ }
+
+ $file = Item::find($upload['fileId']);
+
+ if (!$file) {
+ throw new \Exception("Invalid fileId for resumable file upload.");
+ }
+
+ $from = $params['from'] ?? 0;
+
+ // Sanity checks on the input parameters
+ // TODO: Support uploading again a chunk that already has been uploaded?
+ if ($from < $upload['uploaded'] || $from > $upload['uploaded'] || $from > $upload['size']) {
+ throw new \Exception("Invalid 'from' parameter for resumable file upload.");
+ }
+
+ $disk = LaravelStorage::disk('files');
+
+ $chunkId = \App\Utils::uuidStr();
+
+ $path = self::chunkLocation($chunkId, $file);
+
+ // Save the file chunk
+ $disk->writeStream($path, $stream);
+
+ // Detect file type using the first chunk
+ if ($from == 0) {
+ $upload['mimetype'] = self::mimetype($path);
+ $upload['chunks'] = [];
+ }
+
+ $chunkSize = $disk->fileSize($path);
+
+ // Create the chunk record
+ $file->chunks()->create([
+ 'chunk_id' => $chunkId,
+ 'sequence' => count($upload['chunks']),
+ 'size' => $chunkSize,
+ 'deleted_at' => \now(), // not yet active chunk
+ ]);
+
+ $upload['chunks'][] = $chunkId;
+ $upload['uploaded'] += $chunkSize;
+
+ // Update the file metadata after the upload of all chunks is completed
+ if ($upload['uploaded'] >= $upload['size']) {
+ // Update file metadata
+ $file->setProperties([
+ 'size' => $upload['uploaded'],
+ 'mimetype' => $upload['mimetype'] ?: 'application/octet-stream',
+ ]);
+
+ // Assign uploaded chunks to the file, "unlink" any old chunks of this file
+ $file->chunks()->delete();
+ $file->chunks()->whereIn('chunk_id', $upload['chunks'])->restore();
+
+ // TODO: Create a "cron" job to remove orphaned nodes from DB and the storage.
+ // I.e. all with deleted_at set and older than UPLOAD_TTL
+
+ // Delete the upload cache record
+ Cache::forget('upload:' . $params['uploadId']);
+
+ return ['id' => $file->id];
+ }
+
+ // Update the upload metadata
+ Cache::put('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL);
+
+ return ['uploadId' => $params['uploadId'], 'uploaded' => $upload['uploaded']];
+ }
+
+ /**
+ * Get the file mime type.
+ *
+ * @param string $path File location
+ *
+ * @return string File mime type
+ */
+ protected static function mimetype(string $path): string
+ {
+ $disk = LaravelStorage::disk('files');
+
+ // TODO: If file is empty, detect the mimetype based on the extension?
+ try {
+ return $disk->mimeType($path);
+ } catch (\Exception $e) {
+ // do nothing
+ }
+
+ // TODO: If it fails detect the mimetype based on extension?
+
+ return 'application/octet-stream';
+ }
+
+ /**
+ * Node location in the storage
+ *
+ * @param string $chunkId Chunk identifier
+ * @param \App\Fs\Item $file File the chunk belongs to
+ *
+ * @return string Chunk location
+ */
+ public static function chunkLocation(string $chunkId, Item $file): string
+ {
+ return $file->path . '/' . $file->id . '/' . $chunkId;
+ }
+}
diff --git a/src/app/Fs/Chunk.php b/src/app/Fs/Chunk.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Chunk.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Fs;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * The eloquent definition of a file chunk.
+ *
+ * @property int $id Chunk identifier
+ * @property string $chunk_id Chunk long identifier (storage file name)
+ * @property string $item_id Item identifier
+ * @property int $sequence Chunk sequence number
+ * @property int $size Chunk size
+ */
+class Chunk extends Model
+{
+ use SoftDeletes;
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = ['item_id', 'chunk_id', 'sequence', 'deleted_at', 'size'];
+
+ /** @var string Database table name */
+ protected $table = 'fs_chunks';
+
+
+ /**
+ * The item (file) the chunk belongs to.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function item()
+ {
+ return $this->belongsTo(Item::class);
+ }
+}
diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Item.php
@@ -0,0 +1,178 @@
+<?php
+
+namespace App\Fs;
+
+use App\User;
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * The eloquent definition of a filesystem item.
+ *
+ * @property string $id Item identifier
+ * @property int $type Item type
+ * @property string $path Item path (readonly)
+ * @property int $user_id Item owner
+ */
+class Item extends Model
+{
+ use SoftDeletes;
+ use UuidStrKeyTrait;
+
+ public const TYPE_INCOMPLETE = 0;
+ public const TYPE_FILE = 1;
+ public const TYPE_FOLDER = 2;
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = ['user_id', 'type'];
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'fs_items';
+
+
+ /**
+ * COntent chunks of this item (file).
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function chunks()
+ {
+ return $this->hasMany(Chunk::class);
+ }
+
+ /**
+ * Getter for the file path (without the filename) in the storage.
+ *
+ * @return \Illuminate\Database\Eloquent\Casts\Attribute
+ */
+ protected function path(): Attribute
+ {
+ return Attribute::make(
+ get: function ($value) {
+ if (empty($this->id)) {
+ throw new \Exception("Cannot get path for an item without ID");
+ }
+
+ $id = substr($this->id, 0, 6);
+
+ return implode('/', str_split($id, 2));
+ }
+ );
+ }
+
+ /**
+ * Any (additional) properties of this item.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function properties()
+ {
+ return $this->hasMany(Property::class);
+ }
+
+ /**
+ * Obtain the value for an item property
+ *
+ * @param string $key Property name
+ * @param mixed $default Default value, to be used if not found
+ *
+ * @return string|null Property value
+ */
+ public function getProperty(string $key, $default = null)
+ {
+ $attr = $this->properties()->where('key', $key)->first();
+
+ return $attr ? $attr->value : $default;
+ }
+
+ /**
+ * Obtain the values for many properties in one go (for better performance).
+ *
+ * @param array $keys Property names
+ *
+ * @return array Property key=value hash, includes also requested but non-existing properties
+ */
+ public function getProperties(array $keys): array
+ {
+ $props = array_fill_keys($keys, null);
+
+ $this->properties()->whereIn('key', $keys)->get()
+ ->each(function ($prop) use (&$props) {
+ $props[$prop->key] = $prop->value;
+ });
+
+ return $props;
+ }
+
+ /**
+ * Remove a property
+ *
+ * @param string $key Property name
+ */
+ public function removeProperty(string $key): void
+ {
+ $this->setProperty($key, null);
+ }
+
+ /**
+ * Create or update a property.
+ *
+ * @param string $key Property name
+ * @param string|null $value The new value for the property
+ */
+ public function setProperty(string $key, $value): void
+ {
+ $this->storeProperty($key, $value);
+ }
+
+ /**
+ * Create or update multiple properties in one fell swoop.
+ *
+ * @param array $data An associative array of key value pairs.
+ */
+ public function setProperties(array $data = []): void
+ {
+ foreach ($data as $key => $value) {
+ $this->storeProperty($key, $value);
+ }
+ }
+
+ /**
+ * Create or update a property.
+ *
+ * @param string $key Property name
+ * @param string|null $value The new value for the property
+ */
+ private function storeProperty(string $key, $value): void
+ {
+ if ($value === null || $value === '') {
+ // Note: We're selecting the record first, so observers can act
+ if ($prop = $this->properties()->where('key', $key)->first()) {
+ $prop->delete();
+ }
+ } else {
+ $this->properties()->updateOrCreate(
+ ['key' => $key],
+ ['value' => $value]
+ );
+ }
+ }
+
+ /**
+ * The user to which this item belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id', 'id');
+ }
+}
diff --git a/src/app/Fs/Property.php b/src/app/Fs/Property.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Property.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Fs;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of properties for a filesystem item.
+ *
+ * @property int $id Property identifier
+ * @property int $item_id Item identifier
+ * @property string $key Attribute name
+ * @property string $value Attrbute value
+ */
+class Property extends Model
+{
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = ['item_id', 'key', 'value'];
+
+ /** @var string Database table name */
+ protected $table = 'fs_properties';
+
+
+ /**
+ * The item to which this property belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function item()
+ {
+ return $this->belongsTo(Item::class);
+ }
+}
diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php
--- a/src/app/Handlers/Beta/Base.php
+++ b/src/app/Handlers/Beta/Base.php
@@ -5,6 +5,16 @@
class Base extends \App\Handlers\Base
{
/**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\User::class;
+ }
+
+ /**
* Check if the SKU is available to the user/domain.
*
* @param \App\Sku $sku The SKU object
@@ -67,4 +77,15 @@
return true;
}
+
+ /**
+ * The priority that specifies the order of SKUs in UI.
+ * Higher number means higher on the list.
+ *
+ * @return int
+ */
+ public static function priority(): int
+ {
+ return 10;
+ }
}
diff --git a/src/app/Handlers/Beta/Distlists.php b/src/app/Handlers/Beta/Distlists.php
--- a/src/app/Handlers/Beta/Distlists.php
+++ b/src/app/Handlers/Beta/Distlists.php
@@ -5,16 +5,6 @@
class Distlists extends Base
{
/**
- * The entitleable class for this handler.
- *
- * @return string
- */
- public static function entitleableClass(): string
- {
- return \App\User::class;
- }
-
- /**
* Check if the SKU is available to the user/domain.
*
* @param \App\Sku $sku The SKU object
@@ -35,15 +25,4 @@
return false;
}
-
- /**
- * The priority that specifies the order of SKUs in UI.
- * Higher number means higher on the list.
- *
- * @return int
- */
- public static function priority(): int
- {
- return 10;
- }
}
diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php
--- a/src/app/Handlers/Beta/Resources.php
+++ b/src/app/Handlers/Beta/Resources.php
@@ -5,16 +5,6 @@
class Resources extends Base
{
/**
- * The entitleable class for this handler.
- *
- * @return string
- */
- public static function entitleableClass(): string
- {
- return \App\User::class;
- }
-
- /**
* Check if the SKU is available to the user/domain.
*
* @param \App\Sku $sku The SKU object
@@ -35,15 +25,4 @@
return false;
}
-
- /**
- * The priority that specifies the order of SKUs in UI.
- * Higher number means higher on the list.
- *
- * @return int
- */
- public static function priority(): int
- {
- return 10;
- }
}
diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php
--- a/src/app/Handlers/Beta/SharedFolders.php
+++ b/src/app/Handlers/Beta/SharedFolders.php
@@ -5,16 +5,6 @@
class SharedFolders extends Base
{
/**
- * The entitleable class for this handler.
- *
- * @return string
- */
- public static function entitleableClass(): string
- {
- return \App\User::class;
- }
-
- /**
* Check if the SKU is available to the user/domain.
*
* @param \App\Sku $sku The SKU object
@@ -35,15 +25,4 @@
return false;
}
-
- /**
- * The priority that specifies the order of SKUs in UI.
- * Higher number means higher on the list.
- *
- * @return int
- */
- public static function priority(): int
- {
- return 10;
- }
}
diff --git a/src/app/Handlers/Files.php b/src/app/Handlers/Files.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Files.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace App\Handlers;
+
+class Files extends Beta\Base
+{
+}
diff --git a/src/app/Http/Controllers/API/V4/FilesController.php b/src/app/Http/Controllers/API/V4/FilesController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/FilesController.php
@@ -0,0 +1,611 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Backends\Storage;
+use App\Fs\Item;
+use App\Fs\Property;
+use App\Http\Controllers\RelationController;
+use App\Rules\FileName;
+use App\User;
+use App\Utils;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Validator;
+
+class FilesController extends RelationController
+{
+ protected const READ = 'r';
+ protected const WRITE = 'w';
+
+ /** @var string Resource localization label */
+ protected $label = 'file';
+
+ /** @var string Resource model name */
+ protected $model = Item::class;
+
+
+ /**
+ * Delete a file.
+ *
+ * @param string $id File identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputFile($id, null);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ // FIXME: Here we're just deleting the file, but maybe it would be better/faster
+ // to mark the file (record in db) as deleted and invoke a job to
+ // delete it asynchronously?
+
+ Storage::fileDelete($file);
+
+ $file->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.file-delete-success'),
+ ]);
+ }
+
+ /**
+ * Fetch content of a file.
+ *
+ * @param string $id The download (not file) identifier.
+ *
+ * @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse
+ */
+ public function download($id)
+ {
+ $fileId = Cache::get('download:' . $id);
+
+ if (!$fileId) {
+ return response('Not found', 404);
+ }
+
+ $file = Item::find($fileId);
+
+ if (!$file) {
+ return response('Not found', 404);
+ }
+
+ return Storage::fileDownload($file);
+ }
+
+ /**
+ * Fetch the permissions for the specific file.
+ *
+ * @param string $fileId The file identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function getPermissions($fileId)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputFile($fileId, null);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $result = $file->properties()->where('key', 'like', 'share-%')->get()->map(
+ fn($prop) => self::permissionToClient($prop->key, $prop->value)
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Add permission for the specific file.
+ *
+ * @param string $fileId The file identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function createPermission($fileId)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputFile($fileId, null);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ // Validate/format input
+ $v = Validator::make(request()->all(), [
+ 'user' => 'email|required',
+ 'permissions' => 'string|required',
+ ]);
+
+ $errors = $v->fails() ? $v->errors()->toArray() : [];
+
+ $acl = self::inputAcl(request()->input('permissions'));
+
+ if (empty($errors['permissions']) && empty($acl)) {
+ $errors['permissions'] = \trans('validation.file-perm-invalid');
+ }
+
+ $user = \strtolower(request()->input('user'));
+
+ // Check if it already exists
+ if (empty($errors['user'])) {
+ if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$user:%")->exists()) {
+ $errors['user'] = \trans('validation.file-perm-exists');
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ // Create the property (with a unique id)
+ while ($shareId = 'share-' . \App\Utils::uuidStr()) {
+ if (!Property::where('key', $shareId)->exists()) {
+ break;
+ }
+ }
+
+ $file->setProperty($shareId, "$user:$acl");
+
+ $result = self::permissionToClient($shareId, "$user:$acl");
+
+ return response()->json($result + [
+ 'status' => 'success',
+ 'message' => \trans('app.file-permissions-create-success'),
+ ]);
+ }
+
+ /**
+ * Delete file permission.
+ *
+ * @param string $fileId The file identifier.
+ * @param string $id The file permission identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function deletePermission($fileId, $id)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputFile($fileId, null);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $property = $file->properties()->where('key', $id)->first();
+
+ if (!$property) {
+ return $this->errorResponse(404);
+ }
+
+ $property->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.file-permissions-delete-success'),
+ ]);
+ }
+
+ /**
+ * Update file permission.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $fileId The file identifier.
+ * @param string $id The file permission identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function updatePermission(Request $request, $fileId, $id)
+ {
+ // Only the file owner can do that, for now
+ $file = $this->inputFile($fileId, null);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $property = $file->properties()->where('key', $id)->first();
+
+ if (!$property) {
+ return $this->errorResponse(404);
+ }
+
+ // Validate/format input
+ $v = Validator::make($request->all(), [
+ 'user' => 'email|required',
+ 'permissions' => 'string|required',
+ ]);
+
+ $errors = $v->fails() ? $v->errors()->toArray() : [];
+
+ $acl = self::inputAcl($request->input('permissions'));
+
+ if (empty($errors['permissions']) && empty($acl)) {
+ $errors['permissions'] = \trans('validation.file-perm-invalid');
+ }
+
+ $user = \strtolower($request->input('user'));
+
+ if (empty($errors['user']) && strpos($property->value, "$user:") !== 0) {
+ if ($file->properties()->where('key', 'like', 'share-%')->where('value', 'like', "$user:%")->exists()) {
+ $errors['user'] = \trans('validation.file-perm-exists');
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $property->value = "$user:$acl";
+ $property->save();
+
+ $result = self::permissionToClient($property->key, $property->value);
+
+ return response()->json($result + [
+ 'status' => 'success',
+ 'message' => \trans('app.file-permissions-update-success'),
+ ]);
+ }
+
+ /**
+ * Listing of files (and folders).
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $page = intval(request()->input('page')) ?: 1;
+ $pageSize = 100;
+ $hasMore = false;
+
+ $user = $this->guard()->user();
+
+ $result = $user->fsItems()->select('fs_items.*', 'fs_properties.value as name')
+ ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
+ ->whereNot('type', '&', Item::TYPE_INCOMPLETE)
+ ->where('key', 'name');
+
+ if (strlen($search)) {
+ $result->whereLike('fs_properties.value', $search);
+ }
+
+ $result = $result->orderBy('name')
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($file) {
+ $result = $this->objectToClient($file);
+ $result['name'] = $file->name; // @phpstan-ignore-line
+
+ return $result;
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Fetch the specific file metadata or content.
+ *
+ * @param string $id The file identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\StreamedResponse
+ */
+ public function show($id)
+ {
+ $file = $this->inputFile($id, self::READ);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $response = $this->objectToClient($file, true);
+
+ if (request()->input('downloadUrl')) {
+ // Generate a download URL (that does not require authentication)
+ $downloadId = Utils::uuidStr();
+ Cache::add('download:' . $downloadId, $file->id, 60);
+ $response['downloadUrl'] = Utils::serviceUrl('api/v4/files/downloads/' . $downloadId);
+ } elseif (request()->input('download')) {
+ // Return the file content
+ return Storage::fileDownload($file);
+ }
+
+ $response['mtime'] = $file->updated_at->format('Y-m-d H:i');
+
+ // TODO: Handle read-write/full access rights
+ $isOwner = $this->guard()->user()->id == $file->user_id;
+ $response['canUpdate'] = $isOwner;
+ $response['canDelete'] = $isOwner;
+ $response['isOwner'] = $isOwner;
+
+ return response()->json($response);
+ }
+
+ /**
+ * Create a new file.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ $user = $this->guard()->user();
+
+ // Validate file name input
+ $v = Validator::make($request->all(), ['name' => ['required', new FileName($user)]]);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $filename = $request->input('name');
+ $media = $request->input('media');
+
+ // FIXME: Normally people just drag and drop/upload files.
+ // The client side will not know whether the file with the same name
+ // already exists or not. So, in such a case should we throw
+ // an error or accept the request as an update?
+
+ $params = [];
+
+ if ($media == 'resumable') {
+ $params['uploadId'] = 'resumable';
+ $params['size'] = $request->input('size');
+ $params['from'] = $request->input('from') ?: 0;
+ }
+
+ // TODO: Delete the existing incomplete file with the same name?
+
+ $file = $user->fsItems()->create(['type' => Item::TYPE_INCOMPLETE | Item::TYPE_FILE]);
+ $file->setProperty('name', $filename);
+
+ try {
+ $response = Storage::fileInput($request->getContent(true), $params, $file);
+
+ $response['status'] = 'success';
+
+ if (!empty($response['id'])) {
+ $response += $this->objectToClient($file, true);
+ $response['message'] = \trans('app.file-create-success');
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ $file->delete();
+ return $this->errorResponse(500);
+ }
+
+ return response()->json($response);
+ }
+
+ /**
+ * Update a file.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id File identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $file = $this->inputFile($id, self::WRITE);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $media = $request->input('media') ?: 'metadata';
+
+ if ($media == 'metadata') {
+ $filename = $request->input('name');
+
+ // Validate file name input
+ if ($filename != $file->getProperty('name')) {
+ $v = Validator::make($request->all(), ['name' => [new FileName($file->user)]]);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $file->setProperty('name', $filename);
+ }
+
+ // $file->save();
+ } elseif ($media == 'resumable' || $media == 'content') {
+ $params = [];
+
+ if ($media == 'resumable') {
+ $params['uploadId'] = 'resumable';
+ $params['size'] = $request->input('size');
+ $params['from'] = $request->input('from') ?: 0;
+ }
+
+ try {
+ $response = Storage::fileInput($request->getContent(true), $params, $file);
+ } catch (\Exception $e) {
+ \Log::error($e);
+ return $this->errorResponse(500);
+ }
+ } else {
+ $errors = ['media' => \trans('validation.entryinvalid', ['attribute' => 'media'])];
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $response['status'] = 'success';
+
+ if ($media == 'metadata' || !empty($response['id'])) {
+ $response += $this->objectToClient($file, true);
+ $response['message'] = \trans('app.file-update-success');
+ }
+
+ return response()->json($response);
+ }
+
+ /**
+ * Upload a file content.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Upload (not file) identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function upload(Request $request, $id)
+ {
+ $params = [
+ 'uploadId' => $id,
+ 'from' => $request->input('from') ?: 0,
+ ];
+
+ try {
+ $response = Storage::fileInput($request->getContent(true), $params);
+
+ $response['status'] = 'success';
+
+ if (!empty($response['id'])) {
+ $response += $this->objectToClient(Item::find($response['id']), true);
+ $response['message'] = \trans('app.file-upload-success');
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ return $this->errorResponse(500);
+ }
+
+ return response()->json($response);
+ }
+
+ /**
+ * Convert Permission to an array for the API response.
+ *
+ * @param string $id Permission identifier
+ * @param string $value Permission record
+ *
+ * @return array Permission data
+ */
+ protected static function permissionToClient(string $id, string $value): array
+ {
+ list($user, $acl) = explode(':', $value);
+
+ $perms = strpos($acl, self::WRITE) !== false ? 'read-write' : 'read-only';
+
+ return [
+ 'id' => $id,
+ 'user' => $user,
+ 'permissions' => $perms,
+ 'link' => Utils::serviceUrl('file/' . $id),
+ ];
+ }
+
+ /**
+ * Convert ACL label into internal permissions spec.
+ *
+ * @param string $acl Access rights label
+ *
+ * @return ?string Permissions ('r' or 'rw')
+ */
+ protected static function inputAcl($acl): ?string
+ {
+ // The ACL widget supports 'full', 'read-write', 'read-only',
+ if ($acl == 'read-write') {
+ return self::READ . self::WRITE;
+ }
+
+ if ($acl == 'read-only') {
+ return self::READ;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the input file object, check permissions
+ *
+ * @param string $fileId File or file permission identifier
+ * @param ?string $permission Required access rights
+ *
+ * @return \App\Fs\Item|int File object or error code
+ */
+ protected function inputFile($fileId, $permission)
+ {
+ $user = $this->guard()->user();
+ $isShare = str_starts_with($fileId, 'share-');
+
+ // Access via file permission identifier
+ if ($isShare) {
+ $property = Property::where('key', $fileId)->first();
+
+ if (!$property) {
+ return 404;
+ }
+
+ list($acl_user, $acl) = explode(':', $property->value);
+
+ if (!$permission || $acl_user != $user->email || strpos($acl, $permission) === false) {
+ return 403;
+ }
+
+ $fileId = $property->item_id;
+ }
+
+ $file = Item::find($fileId);
+
+ if (!$file) {
+ return 404;
+ }
+
+ if (!$isShare && $user->id != $file->user_id) {
+ return 403;
+ }
+
+ return $file;
+ }
+
+ /**
+ * Prepare a file object for the UI.
+ *
+ * @param object $object An object
+ * @param bool $full Include all object properties
+ *
+ * @return array Object information
+ */
+ protected function objectToClient($object, bool $full = false): array
+ {
+ $result = ['id' => $object->id];
+
+ if ($full) {
+ $props = array_filter($object->getProperties(['name', 'size', 'mimetype']));
+
+ $props['size'] *= 1; // convert to int
+ $result += $props;
+ }
+
+ return $result;
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -185,6 +185,7 @@
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus),
+ 'enableFiles' => in_array('files', $skus),
// TODO: Make 'enableFolders' working for wallet controllers that aren't account owners
'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
diff --git a/src/app/Http/Middleware/ContentSecurityPolicy.php b/src/app/Http/Middleware/ContentSecurityPolicy.php
--- a/src/app/Http/Middleware/ContentSecurityPolicy.php
+++ b/src/app/Http/Middleware/ContentSecurityPolicy.php
@@ -25,7 +25,7 @@
foreach ($headers as $opt => $header) {
if ($value = \config("app.headers.{$opt}")) {
- $next->header($header, $value);
+ $next->headers->set($header, $value);
}
}
diff --git a/src/app/Rules/FileName.php b/src/app/Rules/FileName.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/FileName.php
@@ -0,0 +1,83 @@
+<?php
+
+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.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $name The value to validate
+ *
+ * @return bool
+ */
+ public function passes($attribute, $name): bool
+ {
+ if (empty($name) || !is_string($name)) {
+ $this->message = \trans('validation.file-name-invalid');
+ return false;
+ }
+
+ // Check the max length, according to the database column length
+ if (strlen($name) > 512) {
+ $this->message = \trans('validation.max.string', ['max' => 512]);
+ return false;
+ }
+
+ // Non-allowed characters
+ if (preg_match('|[\x00-\x1F\/*"\x7F]|', $name)) {
+ $this->message = \trans('validation.file-name-invalid');
+ return false;
+ }
+
+ // Leading/trailing spaces, or all spaces
+ if (preg_match('|^\s+$|', $name) || preg_match('|^\s+|', $name) || preg_match('|\s+$|', $name)) {
+ $this->message = \trans('validation.file-name-invalid');
+ return false;
+ }
+
+ // FIXME: Should we require a dot?
+
+ // Check if the name is unique
+ $exists = $this->owner->fsItems()
+ ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
+ ->where('key', 'name')
+ ->where('value', $name)
+ ->exists();
+
+ if ($exists) {
+ $this->message = \trans('validation.file-name-exists');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -349,6 +349,16 @@
}
/**
+ * Storage items for this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function fsItems()
+ {
+ return $this->hasMany(Fs\Item::class);
+ }
+
+ /**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -27,6 +27,7 @@
use SettingsTrait;
use UuidStrKeyTrait;
+ /** @var bool Indicates that the model should be timestamped or not */
public $timestamps = false;
/** @var array The attributes' default values */
diff --git a/src/config/filesystems.php b/src/config/filesystems.php
--- a/src/config/filesystems.php
+++ b/src/config/filesystems.php
@@ -35,6 +35,11 @@
'root' => storage_path('app'),
],
+ 'files' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/files'),
+ ],
+
'pgp' => [
'driver' => 'local',
'root' => storage_path('app/keys'),
diff --git a/src/config/octane.php b/src/config/octane.php
--- a/src/config/octane.php
+++ b/src/config/octane.php
@@ -230,7 +230,7 @@
'swoole' => [
'options' => [
'log_file' => storage_path('logs/swoole_http.log'),
- 'package_max_length' => 10 * 1024 * 1024,
+ 'package_max_length' => env('SWOOLE_PACKAGE_MAX_LENGTH', 10 * 1024 * 1024),
'enable_coroutine' => false,
//FIXME the daemonize option does not work
// 'daemonize' => env('OCTANE_DAEMONIZE', true),
diff --git a/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php
@@ -0,0 +1,89 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'fs_items',
+ function (Blueprint $table) {
+ $table->string('id', 36)->primary();
+ $table->bigInteger('user_id')->index();
+ $table->integer('type')->unsigned()->default(0);
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ Schema::create(
+ 'fs_properties',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('item_id', 36);
+ $table->string('key')->index();
+ $table->text('value');
+
+ $table->timestamps();
+
+ $table->unique(['item_id', 'key']);
+
+ $table->foreign('item_id')->references('id')->on('fs_items')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+
+ Schema::create(
+ 'fs_chunks',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('item_id', 36);
+ $table->string('chunk_id', 36);
+ $table->integer('sequence')->default(0);
+ $table->integer('size')->unsigned()->default(0);
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->unique(['item_id', 'chunk_id']);
+ // $table->unique(['item_id', 'sequence', 'deleted_at']);
+
+ $table->foreign('item_id')->references('id')->on('fs_items')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ if (!\App\Sku::where('title', 'files')->first()) {
+ \App\Sku::create([
+ 'title' => 'files',
+ 'name' => 'File storage',
+ 'description' => 'Access to file storage',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Files',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('fs_properties');
+ Schema::dropIfExists('fs_chunks');
+ Schema::dropIfExists('fs_items');
+ }
+};
diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php
--- a/src/database/seeds/local/SkuSeeder.php
+++ b/src/database/seeds/local/SkuSeeder.php
@@ -256,6 +256,22 @@
]);
}
+ // Check existence because migration might have added this already
+ $sku = Sku::where(['title' => 'files', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'files',
+ 'name' => 'File storage',
+ 'description' => 'Access to file storage',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Files',
+ 'active' => true,
+ ]);
+ }
+
// for tenants that are not the configured tenant id
$tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get();
diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php
--- a/src/database/seeds/production/SkuSeeder.php
+++ b/src/database/seeds/production/SkuSeeder.php
@@ -241,5 +241,19 @@
'active' => true,
]);
}
+
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', 'files')->first()) {
+ Sku::create([
+ 'title' => 'files',
+ 'name' => 'File storage',
+ 'description' => 'Access to file storage',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Files',
+ 'active' => true,
+ ]);
+ }
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -161,7 +161,7 @@
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
- axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => {
+ axios.post('api/auth/refresh', { refresh_token: response.refresh_token }).then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
@@ -255,7 +255,7 @@
this.errorPage(status, message)
}
},
- downloadFile(url) {
+ downloadFile(url, filename) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
@@ -263,13 +263,16 @@
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
- const contentDisposition = response.headers['content-disposition']
- let filename = 'unknown'
- if (contentDisposition) {
- const match = contentDisposition.match(/filename="(.+)"/);
- if (match.length === 2) {
- filename = match[1];
+ if (!filename) {
+ const contentDisposition = response.headers['content-disposition']
+ filename = 'unknown'
+
+ if (contentDisposition) {
+ const match = contentDisposition.match(/filename="?(.+)"?/);
+ if (match && match.length === 2) {
+ filename = match[1];
+ }
}
}
diff --git a/src/resources/js/files.js b/src/resources/js/files.js
new file mode 100644
--- /dev/null
+++ b/src/resources/js/files.js
@@ -0,0 +1,191 @@
+
+function FileAPI(params = {})
+{
+ // Initial max size of a file chunk in an upload request
+ // Note: The value may change to the value provided by the server on the first upload.
+ // Note: That chunk size here is only body, Swoole's package_max_length is body + headers,
+ // so don't forget to subtract some margin (e.g. 8KB)
+ // FIXME: From my preliminary tests it looks like on the PHP side you need
+ // about 3-4 times as much memory as the request size when using Swoole
+ // (only 1 time without Swoole). And I didn't find a way to lower the memory usage,
+ // it looks like it happens before we even start to process the request in FilesController.
+ let maxChunkSize = 5 * 1024 * 1024 - 1024 * 8
+
+ const area = $(params.dropArea)
+
+ // Add hidden input to the drop area, so we can handle upload by click
+ const input = $('<input>')
+ .attr({type: 'file', multiple: true, style: 'visibility: hidden'})
+ .on('change', event => { fileDropHandler(event) })
+ .appendTo(area)
+ .get(0)
+
+ // Register events on the upload area element
+ area.on('click', () => input.click())
+ .on('drop', event => fileDropHandler(event))
+ .on('dragenter dragleave drop', event => fileDragHandler(event))
+ .on('dragover', event => event.preventDefault()) // prevents file from being opened on drop)
+
+ // Handle dragging on the whole page, so we can style the area in a different way
+ $(document.documentElement).off('.fileapi')
+ .on('dragenter.fileapi dragleave.fileapi', event => area.toggleClass('dragactive'))
+
+ // Handle dragging file(s) - style the upload area element
+ const fileDragHandler = (event) => {
+ if (event.type == 'drop') {
+ area.removeClass('dragover dragactive')
+ } else {
+ area[event.type == 'dragenter' ? 'addClass' : 'removeClass']('dragover')
+ }
+ }
+
+ // Handler for both a ondrop event and file input onchange event
+ const fileDropHandler = (event) => {
+ let files = event.target.files || event.dataTransfer.files
+
+ if (!files || !files.length) {
+ return
+ }
+
+ // Prevent default behavior (prevent file from being opened on drop)
+ event.preventDefault();
+
+ // TODO: Check file size limit, limit number of files to upload at once?
+
+ // For every file...
+ for (const file of files) {
+ const progress = {
+ id: Date.now(),
+ name: file.name,
+ total: file.size,
+ completed: 0
+ }
+
+ file.uploaded = 0
+
+ // Upload request configuration
+ const config = {
+ onUploadProgress: progressEvent => {
+ progress.completed = Math.round(((file.uploaded + progressEvent.loaded) * 100) / file.size)
+
+ // Don't trigger the event when 100% of the file has been sent
+ // We need to wait until the request response is available, then
+ // we'll trigger it (see below where the axios request is created)
+ if (progress.completed < 100) {
+ params.eventHandler('upload-progress', progress)
+ }
+ },
+ headers: {
+ 'Content-Type': file.type
+ },
+ ignoreErrors: true, // skip the Kolab4 interceptor
+ params: { name: file.name },
+ maxBodyLength: -1, // no limit
+ timeout: 0, // no limit
+ transformRequest: [] // disable body transformation
+ }
+
+ // FIXME: It might be better to handle multiple-files upload as a one
+ // "progress source", i.e. we might want to refresh the files list once
+ // all files finished uploading, not multiple times in the middle
+ // of the upload process.
+
+ params.eventHandler('upload-progress', progress)
+
+ // A "recursive" function to upload the file in chunks (if needed)
+ const uploadFn = (start = 0, uploadId) => {
+ let url = 'api/v4/files'
+ let body = ''
+
+ if (file.size <= maxChunkSize) {
+ // The file is small, we'll upload it using a single request
+ // Note that even in this case the auth token might expire while
+ // the file is uploading, but the risk is quite small.
+ body = file
+ start += maxChunkSize
+ } else if (!uploadId) {
+ // The file is big, first send a request for the upload location
+ // The upload location does not require authentication, which means
+ // there should be no problem with expired auth token, etc.
+ config.params.media = 'resumable'
+ config.params.size = file.size
+ } else {
+ // Upload a chunk of the file to the upload location
+ url = 'api/v4/files/uploads/' + uploadId
+ body = file.slice(start, start + maxChunkSize, file.type)
+
+ config.params = { from: start }
+ config.headers.Authorization = ''
+ start += maxChunkSize
+ }
+
+ axios.post(url, body, config)
+ .then(response => {
+ if (response.data.maxChunkSize) {
+ maxChunkSize = response.data.maxChunkSize
+ }
+
+ if (start < file.size) {
+ file.uploaded = start
+ uploadFn(start, uploadId || response.data.uploadId)
+ } else {
+ progress.completed = 100
+ params.eventHandler('upload-progress', progress)
+ }
+ })
+ .catch(error => {
+ console.log(error)
+
+ // TODO: Depending on the error consider retrying the request
+ // if it was one of many chunks of a bigger file?
+
+ progress.error = error
+ progress.completed = 100
+ params.eventHandler('upload-progress', progress)
+ })
+ }
+
+ // Start uploading
+ uploadFn()
+ }
+ }
+
+ /**
+ * Download a file. Starts downloading using a hidden link trick.
+ */
+ this.fileDownload = (id) => {
+ axios.get('api/v4/files/' + id + '?downloadUrl=1')
+ .then(response => {
+ // Create a dummy link element and click it
+ if (response.data.downloadUrl) {
+ $('<a>').attr('href', response.data.downloadUrl).get(0).click()
+ }
+ })
+ }
+
+ /**
+ * Rename a file.
+ */
+ this.fileRename = (id, name) => {
+ axios.put('api/v4/files/' + id, { name })
+ .then(response => {
+
+ })
+ }
+
+ /**
+ * Convert file size as a number of bytes to a human-readable format
+ */
+ this.sizeText = (bytes) => {
+ if (bytes >= 1073741824)
+ return parseFloat(bytes/1073741824).toFixed(2) + ' GB';
+ if (bytes >= 1048576)
+ return parseFloat(bytes/1048576).toFixed(2) + ' MB';
+ if (bytes >= 1024)
+ return parseInt(bytes/1024) + ' kB';
+
+ return parseInt(bytes || 0) + ' B';
+ }
+}
+
+export default FileAPI
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -14,6 +14,8 @@
const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List')
const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info')
const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List')
+const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info')
+const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List')
const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms')
const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info')
const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List')
@@ -65,6 +67,18 @@
meta: { requiresAuth: true, perm: 'domains' }
},
{
+ path: '/file/:file',
+ name: 'file',
+ component: FileInfoComponent,
+ meta: { requiresAuth: true /*, perm: 'files' */ }
+ },
+ {
+ path: '/files',
+ name: 'files',
+ component: FileListComponent,
+ meta: { requiresAuth: true, perm: 'files' }
+ },
+ {
path: '/login',
name: 'login',
component: LoginComponent
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -70,6 +70,13 @@
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
+ 'file-create-success' => 'File created successfully.',
+ 'file-delete-success' => 'File deleted successfully.',
+ 'file-update-success' => 'File updated successfully.',
+ 'file-permissions-create-success' => 'File permissions created successfully.',
+ 'file-permissions-update-success' => 'File permissions updated successfully.',
+ 'file-permissions-delete-success' => 'File permissions deleted successfully.',
+
'resource-update-success' => 'Resource updated successfully.',
'resource-create-success' => 'Resource created successfully.',
'resource-delete-success' => 'Resource deleted successfully.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -31,6 +31,7 @@
'resend' => "Resend",
'save' => "Save",
'search' => "Search",
+ 'share' => "Share",
'signup' => "Sign Up",
'submit' => "Submit",
'suspend' => "Suspend",
@@ -60,6 +61,7 @@
'chat' => "Video chat",
'companion' => "Companion app",
'domains' => "Domains",
+ 'files' => "Files",
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
@@ -126,6 +128,19 @@
'form' => "Form validation error",
],
+ 'file' => [
+ 'create' => "Create file",
+ 'delete' => "Delete file",
+ 'list-empty' => "There are no files in this account.",
+ 'mimetype' => "Mimetype",
+ 'mtime' => "Modified",
+ 'new' => "New file",
+ 'search' => "File name",
+ 'sharing' => "Sharing",
+ 'sharing-links-text' => "You can share the file with other users by giving them read-only access "
+ . "to the file via a unique link.",
+ ],
+
'form' => [
'acl' => "Access rights",
'acl-full' => "All",
@@ -154,6 +169,7 @@
'phone' => "Phone",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
+ 'size' => "Size",
'status' => "Status",
'surname' => "Surname",
'type' => "Type",
@@ -302,6 +318,7 @@
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
+ 'uploading' => "Uploading...",
'warning' => "Warning",
'success' => "Success",
],
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -153,6 +153,11 @@
'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.',
'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.',
+ 'file-perm-exists' => 'File permission already exists.',
+ 'file-perm-invalid' => 'The file permission is invalid.',
+ 'file-name-exists' => 'The file name already exists.',
+ 'file-name-invalid' => 'The file name is invalid.',
+ 'file-name-toolong' => 'The file name is too long.',
'ipolicy-invalid' => 'The specified invitation policy is invalid.',
'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
'nameexists' => 'The specified name is not available.',
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -137,7 +137,9 @@
td.buttons,
th.price,
- td.price {
+ td.price,
+ th.size,
+ td.size {
width: 1%;
text-align: right;
white-space: nowrap;
@@ -174,6 +176,38 @@
margin-left: .4em;
}
}
+
+ &.files {
+ table-layout: fixed;
+
+ td {
+ white-space: nowrap;
+ }
+
+ td.name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+/*
+ td.size,
+ th.size {
+ width: 80px;
+ }
+
+ td.mtime,
+ th.mtime {
+ width: 140px;
+
+ @include media-breakpoint-down(sm) {
+ display: none;
+ }
+ }
+*/
+ td.buttons,
+ th.buttons {
+ width: 50px;
+ }
+ }
}
.list-details {
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -169,3 +169,32 @@
}
}
}
+
+.file-drop-area {
+ display: inline;
+ background: $menu-bg-color;
+ color: grey;
+ font-size: 0.9rem;
+ font-weight: normal;
+ line-height: 2;
+ border: 1px solid #eee;
+ border-radius: 0.5em;
+ padding: 0.5em;
+ cursor: pointer;
+ position: relative;
+
+ input {
+ position: absolute;
+ height: 10px;
+ }
+
+ &.dragactive {
+ border: 1px dashed #aaa;
+ }
+
+ &.dragover {
+ background-color: rgba($main-color, 0.25);
+ border: 1px dashed $main-color;
+ color: $main-color;
+ }
+}
diff --git a/src/resources/themes/toast.scss b/src/resources/themes/toast.scss
--- a/src/resources/themes/toast.scss
+++ b/src/resources/themes/toast.scss
@@ -51,3 +51,17 @@
.toast-body {
color: #fff;
}
+
+.toast-progress {
+ margin: 0.5em;
+ margin-top: 0;
+ height: 3px;
+ background: #222;
+ border-radius: 1.5px;
+ overflow: hidden;
+}
+
+.toast-progress-bar {
+ height: 100%;
+ background: $main-color;
+}
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -32,6 +32,10 @@
<svg-icon icon="comments"></svg-icon><span class="name">{{ $t('dashboard.chat') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
+ <router-link v-if="status.enableFiles && !$root.isDegraded()" class="card link-files" :to="{ name: 'files' }">
+ <svg-icon icon="folder-open"></svg-icon><span class="name">{{ $t('dashboard.files') }}</span>
+ <span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
+ </router-link>
<router-link v-if="status.enableSettings" class="card link-settings" :to="{ name: 'settings' }">
<svg-icon icon="sliders-h"></svg-icon><span class="name">{{ $t('dashboard.settings') }}</span>
</router-link>
diff --git a/src/resources/vue/File/Info.vue b/src/resources/vue/File/Info.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/File/Info.vue
@@ -0,0 +1,163 @@
+<template>
+ <div class="container">
+ <div class="card" id="file-info">
+ <div class="card-body">
+ <div class="card-title">
+ {{ file.name }}
+ <btn v-if="file.canDelete" class="btn-outline-danger button-delete float-end" @click="fileDelete" icon="trash-alt">{{ $t('file.delete') }}</btn>
+ </div>
+ <div class="card-text">
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('form.general') }}
+ </a>
+ </li>
+ <li class="nav-item" v-if="file.isOwner">
+ <a class="nav-link" id="tab-sharing" href="#sharing" role="tab" aria-controls="sharing" aria-selected="false" @click="$root.tab">
+ {{ $t('file.sharing') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <form class="tab-pane show active card-body read-only short" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <div class="row plaintext">
+ <label for="mimetype" class="col-sm-4 col-form-label">{{ $t('file.mimetype') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="mimetype">{{ file.mimetype }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="size" class="col-sm-4 col-form-label">{{ $t('form.size') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="size">{{ api.sizeText(file.size) }}</span>
+ </div>
+ </div>
+ <div class="row plaintext mb-3">
+ <label for="mtime" class="col-sm-4 col-form-label">{{ $t('file.mtime') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="mtime">{{ file.mtime }}</span>
+ </div>
+ </div>
+ <btn class="btn-primary" icon="download" @click="fileDownload">{{ $t('btn.download') }}</btn>
+ </form>
+ <div v-if="file.isOwner" class="tab-pane card-body" id="sharing" role="tabpanel" aria-labelledby="tab-sharing">
+ <div id="share-form" class="mb-3">
+ <div class="row">
+ <small id="share-links-hint" class="text-muted mb-2">
+ {{ $t('file.sharing-links-text') }}
+ </small>
+ <div class="input-group">
+ <input type="text" class="form-control" id="user" :placeholder="$t('form.email')">
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="shareAdd">
+ <svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span>
+ </a>
+ </div>
+ </div>
+ </div>
+ <div id="share-links" class="row m-0" v-if="shares.length">
+ <div class="list-group p-0">
+ <div v-for="(item, index) in shares" :key="item.id" class="list-group-item">
+ <div class="d-flex w-100 justify-content-between">
+ <span class="user lh-lg">
+ <svg-icon icon="user"></svg-icon> {{ item.user }}
+ </span>
+ <span class="d-inline-block">
+ <btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="copyLink(item.link)"></btn>
+ <btn class="btn-link text-danger p-1" icon="trash-alt" :title="$t('btn.delete')" @click="shareDelete(item.id)"></btn>
+ </span>
+ </div>
+ <code>{{ item.link }}</code>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import FileAPI from '../../js/files.js'
+
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faDownload } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faDownload)
+
+ export default {
+ data() {
+ return {
+ file: {},
+ fileId: null,
+ shares: []
+ }
+ },
+ created() {
+ this.api = new FileAPI({})
+
+ this.fileId = this.$route.params.file
+
+ this.$root.startLoading()
+
+ axios.get('/api/v4/files/' + this.fileId)
+ .then(response => {
+ this.$root.stopLoading()
+ 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)
+ },
+ methods: {
+ copyLink(link) {
+ navigator.clipboard.writeText(link);
+ },
+ fileDelete() {
+ axios.delete('api/v4/files/' + this.fileId)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'files' })
+ }
+ })
+ },
+ fileDownload() {
+ this.api.fileDownload(this.fileId)
+ },
+ shareAdd() {
+ let post = { permissions: 'read-only', user: $('#user').val() }
+
+ if (!post.user) {
+ return
+ }
+
+ axios.post('api/v4/files/' + this.fileId + '/permissions', post)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.shares.push(response.data)
+ }
+ })
+ },
+ shareDelete(id) {
+ axios.delete('api/v4/files/' + 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))
+ }
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/File/List.vue b/src/resources/vue/File/List.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/File/List.vue
@@ -0,0 +1,133 @@
+<template>
+ <div class="container">
+ <div class="card" id="files">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $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>
+ </div>
+ <div class="card-text pt-4">
+ <div class="mb-2 d-flex w-100">
+ <list-search :placeholder="$t('file.search')" :on-search="searchFiles"></list-search>
+ </div>
+ <table class="table table-sm table-hover files">
+ <thead>
+ <tr>
+ <th scope="col" class="name">{{ $t('form.name') }}</th>
+ <th scope="col" class="buttons"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="file in files" :key="file.id" @click="$root.clickRecord">
+ <td class="name">
+ <svg-icon icon="file" :title="file.mimetype"></svg-icon>
+ <router-link :to="{ path: 'file/' + file.id }">{{ file.name }}</router-link>
+ </td>
+ <td class="buttons">
+ <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-alt" :title="$t('btn.delete')"></btn>
+ </td>
+ </tr>
+ </tbody>
+ <list-foot :colspan="2" :text="$t('file.list-empty')"></list-foot>
+ </table>
+ <list-more v-if="hasMore" :on-click="loadFiles"></list-more>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import FileAPI from '../../js/files.js'
+ import ListTools from '../Widgets/ListTools'
+
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faFile, faDownload, faUpload } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faFile, faDownload, faUpload)
+
+ export default {
+ mixins: [ ListTools ],
+ data() {
+ return {
+ api: {},
+ file: null,
+ files: []
+ }
+ },
+ mounted() {
+ this.uploads = {}
+
+ this.api = new FileAPI({
+ dropArea: '#drop-area',
+ eventHandler: this.eventHandler
+ })
+
+ this.loadFiles({ init: true })
+ },
+ methods: {
+ eventHandler(name, params) {
+ const camelCase = name.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (m, chr) => chr.toUpperCase())
+ const method = camelCase + 'Handler'
+
+ if (method in this) {
+ this[method](params)
+ }
+ },
+ fileDelete(file) {
+ axios.delete('api/v4/files/' + file.id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ // Refresh the list
+ this.loadFiles({ reset: true })
+ }
+ })
+ },
+ fileDownload(file) {
+ // This is not an appropriate method for big files, we can consider
+ // using it still for very small files.
+ // this.$root.downloadFile('api/v4/files/' + 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)
+ },
+ searchFiles(search) {
+ this.loadFiles({ reset: true, search })
+ },
+ uploadProgressHandler(params) {
+ // Note: There might be more than one event with completed=0
+ // e.g. if you upload multiple files at once
+ if (params.completed == 0 && !(params.id in this.uploads)) {
+ // Create the toast message with progress bar
+ this.uploads[params.id] = this.$toast.message({
+ icon: 'upload',
+ timeout: 24 * 60 * 60 * 60 * 1000,
+ title: this.$t('msg.uploading'),
+ msg: `${params.name} (${this.api.sizeText(params.total)})`,
+ progress: 0
+ })
+ } else if (params.id in this.uploads) {
+ if (params.completed == 100) {
+ this.uploads[params.id].delete() // close the toast message
+ delete this.uploads[params.id]
+
+ // TODO: Reloading the list is probably not the best solution
+ this.loadFiles({ reset: true })
+ } else {
+ // update progress bar
+ this.uploads[params.id].updateProgress(params.completed)
+ }
+ }
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue
--- a/src/resources/vue/Widgets/AclInput.vue
+++ b/src/resources/vue/Widgets/AclInput.vue
@@ -1,7 +1,7 @@
<template>
<div class="list-input acl-input" :id="id">
<div class="input-group">
- <select class="form-select mod mod-user" @change="changeMod" v-model="mod">
+ <select v-if="!useronly" class="form-select mod mod-user" @change="changeMod" v-model="mod">
<option value="user">{{ $t('form.user') }}</option>
<option value="anyone">{{ $t('form.anyone') }}</option>
</select>
@@ -29,7 +29,8 @@
export default {
props: {
list: { type: Array, default: () => [] },
- id: { type: String, default: '' }
+ id: { type: String, default: '' },
+ useronly: { type: Boolean, default: false }
},
data() {
return {
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
@@ -53,7 +53,7 @@
methods: {
listSearch(name, url, params) {
let loader
- let get = {}
+ let get = params.get || {}
if (params) {
if (params.reset || params.init) {
diff --git a/src/resources/vue/Widgets/Toast.vue b/src/resources/vue/Widgets/Toast.vue
--- a/src/resources/vue/Widgets/Toast.vue
+++ b/src/resources/vue/Widgets/Toast.vue
@@ -14,6 +14,8 @@
const instance = new msg({ propsData: { data: data } })
instance.$mount()
$(instance.$el).prependTo(this.$el)
+
+ return instance
},
processObjectData(data) {
if (typeof data === 'object' && data.msg !== undefined) {
diff --git a/src/resources/vue/Widgets/ToastMessage.vue b/src/resources/vue/Widgets/ToastMessage.vue
--- a/src/resources/vue/Widgets/ToastMessage.vue
+++ b/src/resources/vue/Widgets/ToastMessage.vue
@@ -11,6 +11,9 @@
</div>
<div v-if="data.body" v-html="data.body" class="toast-body"></div>
<div v-else class="toast-body">{{ data.msg }}</div>
+ <div v-if="'progress' in data" class="toast-progress">
+ <div class="toast-progress-bar" :style="'width: ' + data.progress + '%'"></div>
+ </div>
</div>
</template>
@@ -52,9 +55,15 @@
return this.data.titleClassName || ''
}
},
+ delete() {
+ new Toast(this.$el).dispose()
+ },
toastClassName() {
return 'toast hide toast-' + this.data.type
+ (this.data.className ? ' ' + this.data.className : '')
+ },
+ updateProgress(percent) {
+ $(this.$el).find('.toast-progress-bar').css('width', percent + '%')
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -81,6 +81,17 @@
Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
+ Route::apiResource('files', API\V4\FilesController::class);
+ Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']);
+ Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']);
+ Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']);
+ Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']);
+ Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload'])
+ ->withoutMiddleware(['auth:api'])
+ ->middleware(['api']);
+ Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download'])
+ ->withoutMiddleware(['auth:api']);
+
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
diff --git a/src/tests/Feature/Controller/FilesTest.php b/src/tests/Feature/Controller/FilesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/FilesTest.php
@@ -0,0 +1,732 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Backends\Storage;
+use App\Fs\Item;
+use App\Fs\Property;
+use App\User;
+use Illuminate\Support\Facades\Storage as LaravelStorage;
+use Tests\TestCase;
+
+class FilesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Item::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ Item::query()->delete();
+
+ $disk = LaravelStorage::disk('files');
+ foreach ($disk->listContents('') as $dir) {
+ $disk->deleteDirectory($dir->path());
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test deleting files (DELETE /api/v4/files/<file-id>)
+ */
+ public function testDelete(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content');
+
+ // Unauth access
+ $response = $this->delete("api/v4/files/{$file->id}");
+ $response->assertStatus(401);
+
+ // Unauth access
+ $response = $this->actingAs($jack)->delete("api/v4/files/{$file->id}");
+ $response->assertStatus(403);
+
+ // Non-existing file
+ $response = $this->actingAs($john)->delete("api/v4/files/123");
+ $response->assertStatus(404);
+
+ // File owner access
+ $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File deleted successfully.", $json['message']);
+
+ // Test that the file has been removed from the filesystem?
+ $disk = LaravelStorage::disk('files');
+ $this->assertFalse($disk->directoryExists($file->path . '/' . $file->id));
+ $this->assertSame(null, Item::find($file->id));
+
+ // Test deletion of a chunked file
+ $file = $this->getTestFile($john, 'test.txt', ['T1', 'T2']);
+
+ $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File deleted successfully.", $json['message']);
+
+ // Test that the file has been removed from the filesystem?
+ $disk = LaravelStorage::disk('files');
+ $this->assertFalse($disk->directoryExists($file->path . '/' . $file->id));
+ $this->assertSame(null, Item::find($file->id));
+
+ // TODO: Test acting as another user with permissions
+ }
+
+ /**
+ * Test file downloads (GET /api/v4/files/downloads/<id>)
+ */
+ public function testDownload(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content');
+
+ // Unauth access
+ $response = $this->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response->assertStatus(401);
+
+ $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response->assertStatus(403);
+
+ // Non-existing file
+ $response = $this->actingAs($john)->get("api/v4/files/123456?downloadUrl=1");
+ $response->assertStatus(404);
+
+ // Get downloadLink for the file
+ $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $link = $json['downloadUrl'];
+
+ // Fetch the file content
+ $response = $this->get(substr($link, strpos($link, '/api/') + 1));
+ $response->assertStatus(200)
+ ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
+ ->assertHeader('Content-Length', $file->getProperty('size'))
+ ->assertHeader('Content-Type', $file->getProperty('mimetype') . '; charset=UTF-8');
+
+ $this->assertSame('Teśt content', $response->streamedContent());
+
+ // Test acting as another user with read permission
+ $permission = $this->getTestFilePermission($file, $jack, 'r');
+ $response = $this->actingAs($jack)->get("api/v4/files/{$permission->key}?downloadUrl=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $link = $json['downloadUrl'];
+
+ // Fetch the file content
+ $response = $this->get(substr($link, strpos($link, '/api/') + 1));
+ $response->assertStatus(200)
+ ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
+ ->assertHeader('Content-Length', $file->getProperty('size'))
+ ->assertHeader('Content-Type', $file->getProperty('mimetype') . '; charset=UTF-8');
+
+ $this->assertSame('Teśt content', $response->streamedContent());
+
+ // Test downloading a multi-chunk file
+ $file = $this->getTestFile($john, 'test2.txt', ['T1', 'T2']);
+ $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?downloadUrl=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $link = $json['downloadUrl'];
+
+ // Fetch the file content
+ $response = $this->get(substr($link, strpos($link, '/api/') + 1));
+ $response->assertStatus(200)
+ ->assertHeader('Content-Disposition', "attachment; filename=test2.txt")
+ ->assertHeader('Content-Length', $file->getProperty('size'))
+ ->assertHeader('Content-Type', $file->getProperty('mimetype'));
+
+ $this->assertSame('T1T2', $response->streamedContent());
+ }
+
+ /**
+ * Test fetching/creating/updaing/deleting file permissions (GET|POST|PUT /api/v4/files/<file-id>/permissions)
+ */
+ public function testPermissions(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'test1.txt', []);
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/files/{$file->id}/permissions");
+ $response->assertStatus(401);
+ $response = $this->post("api/v4/files/{$file->id}/permissions", []);
+ $response->assertStatus(401);
+
+ // Non-existing file
+ $response = $this->actingAs($john)->get("api/v4/files/1234/permissions");
+ $response->assertStatus(404);
+ $response = $this->actingAs($john)->post("api/v4/files/1234/permissions", []);
+ $response->assertStatus(404);
+
+ // No permissions to the file
+ $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}/permissions");
+ $response->assertStatus(403);
+ $response = $this->actingAs($jack)->post("api/v4/files/{$file->id}/permissions", []);
+ $response->assertStatus(403);
+
+ // Expect an empty list of permissions
+ $response = $this->actingAs($john)->get("api/v4/files/{$file->id}/permissions");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame([], $json['list']);
+ $this->assertSame(0, $json['count']);
+
+ // Empty input
+ $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertSame(["The user field is required."], $json['errors']['user']);
+ $this->assertSame(["The permissions field is required."], $json['errors']['permissions']);
+
+ // Test more input validation
+ $post = ['user' => 'user', 'permissions' => 'read'];
+ $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']);
+ $this->assertSame(["The user must be a valid email address."], $json['errors']['user']);
+ $this->assertSame("The file permission is invalid.", $json['errors']['permissions']);
+
+ // Let's add some permission
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only'];
+ $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions created successfully.", $json['message']);
+
+ $permission = $file->properties()->where('key', 'like', 'share-%')->orderBy('value')->first();
+
+ $this->assertSame("{$jack->email}:r", $permission->value);
+ $this->assertSame($permission->key, $json['id']);
+ $this->assertSame($jack->email, $json['user']);
+ $this->assertSame('read-only', $json['permissions']);
+ $this->assertSame(\App\Utils::serviceUrl('file/' . $permission->key), $json['link']);
+
+ // Error handling on use of the same user
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-only'];
+ $response = $this->actingAs($john)->post("api/v4/files/{$file->id}/permissions", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("File permission already exists.", $json['errors']['user']);
+
+ // Test update
+ $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/1234", $post);
+ $response->assertStatus(404);
+
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read-write'];
+ $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/{$permission->key}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions updated successfully.", $json['message']);
+
+ $permission->refresh();
+
+ $this->assertSame("{$jack->email}:rw", $permission->value);
+ $this->assertSame($permission->key, $json['id']);
+ $this->assertSame($jack->email, $json['user']);
+ $this->assertSame('read-write', $json['permissions']);
+ $this->assertSame(\App\Utils::serviceUrl('file/' . $permission->key), $json['link']);
+
+ // Input validation on update
+ $post = ['user' => 'jack@kolab.org', 'permissions' => 'read'];
+ $response = $this->actingAs($john)->put("api/v4/files/{$file->id}/permissions/{$permission->key}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The file permission is invalid.", $json['errors']['permissions']);
+
+ // Test GET with existing permissions
+ $response = $this->actingAs($john)->get("api/v4/files/{$file->id}/permissions");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame(1, $json['count']);
+
+ $this->assertSame($permission->key, $json['list'][0]['id']);
+ $this->assertSame($jack->email, $json['list'][0]['user']);
+ $this->assertSame('read-write', $json['list'][0]['permissions']);
+ $this->assertSame(\App\Utils::serviceUrl('file/' . $permission->key), $json['list'][0]['link']);
+
+ // Delete permission
+ $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}/permissions/1234");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($john)->delete("api/v4/files/{$file->id}/permissions/{$permission->key}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions deleted successfully.", $json['message']);
+
+ $this->assertCount(0, $file->properties()->where('key', 'like', 'share-%')->get());
+ }
+
+ /**
+ * Test fetching files/folders list (GET /api/v4/files)
+ */
+ public function testIndex(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/files");
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ // Expect an empty list
+ $response = $this->actingAs($user)->get("api/v4/files");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame([], $json['list']);
+ $this->assertSame(0, $json['count']);
+ $this->assertSame(false, $json['hasMore']);
+
+ // Create some files and test again
+ $file1 = $this->getTestFile($user, 'test1.txt', [], ['mimetype' => 'text/plain', 'size' => 12345]);
+ $file2 = $this->getTestFile($user, 'test2.gif', [], ['mimetype' => 'image/gif', 'size' => 10000]);
+
+ $response = $this->actingAs($user)->get("api/v4/files");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame(2, $json['count']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame('test1.txt', $json['list'][0]['name']);
+ $this->assertSame($file1->id, $json['list'][0]['id']);
+ $this->assertSame('test2.gif', $json['list'][1]['name']);
+ $this->assertSame($file2->id, $json['list'][1]['id']);
+
+ // Searching
+ $response = $this->actingAs($user)->get("api/v4/files?search=t2");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(3, $json);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame('test2.gif', $json['list'][0]['name']);
+ $this->assertSame($file2->id, $json['list'][0]['id']);
+
+ // TODO: Test paging
+ }
+
+ /**
+ * Test fetching file metadata (GET /api/v4/files/<file-id>)
+ */
+ public function testShow(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/files/1234");
+ $response->assertStatus(401);
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content');
+
+ // Non-existing file
+ $response = $this->actingAs($jack)->get("api/v4/files/1234");
+ $response->assertStatus(404);
+
+ // Unauthorized access
+ $response = $this->actingAs($jack)->get("api/v4/files/{$file->id}");
+ $response->assertStatus(403);
+
+ // Get file metadata
+ $response = $this->actingAs($john)->get("api/v4/files/{$file->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $this->assertSame($file->getProperty('mimetype'), $json['mimetype']);
+ $this->assertSame((int) $file->getProperty('size'), $json['size']);
+ $this->assertSame($file->getProperty('name'), $json['name']);
+ $this->assertSame(true, $json['isOwner']);
+ $this->assertSame(true, $json['canUpdate']);
+ $this->assertSame(true, $json['canDelete']);
+
+ // Get file content
+ $response = $this->actingAs($john)->get("api/v4/files/{$file->id}?download=1");
+ $response->assertStatus(200)
+ ->assertHeader('Content-Disposition', "attachment; filename=test.txt; filename*=utf-8''te%C5%9Bt.txt")
+ ->assertHeader('Content-Length', $file->getProperty('size'))
+ ->assertHeader('Content-Type', $file->getProperty('mimetype') . '; charset=UTF-8');
+
+ $this->assertSame('Teśt content', $response->streamedContent());
+
+ // Test acting as a user with file permissions
+ $permission = $this->getTestFilePermission($file, $jack, 'r');
+ $response = $this->actingAs($jack)->get("api/v4/files/{$permission->key}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $this->assertSame(false, $json['isOwner']);
+ $this->assertSame(false, $json['canUpdate']);
+ $this->assertSame(false, $json['canDelete']);
+ }
+
+ /**
+ * Test creating files (POST /api/v4/files)
+ */
+ public function testStore(): void
+ {
+ // Unauth access not allowed
+ $response = $this->post("api/v4/files");
+ $response->assertStatus(401);
+
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test input validation
+ $response = $this->sendRawBody($john, 'POST', "api/v4/files", [], '');
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The name field is required."], $json['errors']['name']);
+
+ $response = $this->sendRawBody($john, 'POST', "api/v4/files?name=*.txt", [], '');
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The file name is invalid."], $json['errors']['name']);
+
+ // Create a file - the simple method
+ $body = "test content";
+ $headers = [];
+ $response = $this->sendRawBody($john, 'POST', "api/v4/files?name=test.txt", $headers, $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File created successfully.", $json['message']);
+ $this->assertSame('text/plain', $json['mimetype']);
+ $this->assertSame(strlen($body), $json['size']);
+ $this->assertSame('test.txt', $json['name']);
+
+ $file = Item::find($json['id']);
+
+ $this->assertSame(Item::TYPE_FILE, $file->type);
+ $this->assertSame($json['mimetype'], $file->getProperty('mimetype'));
+ $this->assertSame($json['size'], (int) $file->getProperty('size'));
+ $this->assertSame($json['name'], $file->getProperty('name'));
+ $this->assertSame($body, $this->getTestFileContent($file));
+ }
+
+ /**
+ * Test creating files - resumable (POST /api/v4/files)
+ */
+ public function testStoreResumable(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $response = $this->actingAs($john)->post("api/v4/files?name=test2.txt&media=resumable&size=400");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['uploaded']);
+ $this->assertMatchesRegularExpression(
+ '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/',
+ $json['uploadId']
+ );
+
+ $uploadId = $json['uploadId'];
+ $size = 0;
+ $fileContent = '';
+
+ for ($x = 0; $x <= 2; $x++) {
+ $body = str_repeat("$x", 100);
+ $response = $this->sendRawBody(null, 'POST', "api/v4/files/uploads/{$uploadId}?from={$size}", [], $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $size += 100;
+ $fileContent .= $body;
+
+ $this->assertSame($size, $json['uploaded']);
+ $this->assertSame($uploadId, $json['uploadId']);
+ }
+
+ $body = str_repeat("$x", 100);
+ $response = $this->sendRawBody(null, 'POST', "api/v4/files/uploads/{$uploadId}?from={$size}", [], $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $size += 100;
+ $fileContent .= $body;
+
+ $this->assertSame('success', $json['status']);
+ // $this->assertSame("", $json['message']);
+ $this->assertSame('text/plain', $json['mimetype']);
+ $this->assertSame($size, $json['size']);
+ $this->assertSame('test2.txt', $json['name']);
+
+ $file = Item::find($json['id']);
+
+ $this->assertSame(Item::TYPE_FILE, $file->type);
+ $this->assertSame($json['mimetype'], $file->getProperty('mimetype'));
+ $this->assertSame($json['size'], (int) $file->getProperty('size'));
+ $this->assertSame($json['name'], $file->getProperty('name'));
+ $this->assertSame($fileContent, $this->getTestFileContent($file));
+ }
+
+ /**
+ * Test updating files (PUT /api/v4/files/<file-id>)
+ */
+ public function testUpdate(): void
+ {
+ // Unauth access not allowed
+ $response = $this->put("api/v4/files/1234");
+ $response->assertStatus(401);
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $file = $this->getTestFile($john, 'teśt.txt', 'Teśt content');
+
+ // Non-existing file
+ $response = $this->actingAs($john)->put("api/v4/files/1234", []);
+ $response->assertStatus(404);
+
+ // Unauthorized access
+ $response = $this->actingAs($jack)->put("api/v4/files/{$file->id}", []);
+ $response->assertStatus(403);
+
+ // Test name validation
+ $post = ['name' => 'test/test.txt'];
+ $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(["The file name is invalid."], $json['errors']['name']);
+
+ $post = ['name' => 'new name.txt', 'media' => 'test'];
+ $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The specified media is invalid.", $json['errors']['media']);
+
+ // Rename a file
+ $post = ['name' => 'new namś.txt'];
+ $response = $this->actingAs($john)->put("api/v4/files/{$file->id}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File updated successfully.", $json['message']);
+
+ $file->refresh();
+
+ $this->assertSame($post['name'], $file->getProperty('name'));
+
+ // Update file content
+ $body = "Test1\nTest2";
+ $response = $this->sendRawBody($john, 'PUT', "api/v4/files/{$file->id}?media=content", [], $body);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File updated successfully.", $json['message']);
+
+ $file->refresh();
+
+ $this->assertSame($body, $this->getTestFileContent($file));
+ $this->assertSame('text/plain', $file->getProperty('mimetype'));
+ $this->assertSame(strlen($body), (int) $file->getProperty('size'));
+
+ // TODO: Test acting as another user with file permissions
+ // TODO: Test media=resumable
+ }
+
+ /**
+ * Create a test file.
+ *
+ * @param \App\User $user File owner
+ * @param string $name File name
+ * @param string|array $content File content
+ * @param array $props Extra file properties
+ *
+ * @return \App\Fs\Item
+ */
+ protected function getTestFile(User $user, string $name, $content = [], $props = []): Item
+ {
+ $disk = LaravelStorage::disk('files');
+
+ $file = $user->fsItems()->create(['type' => Item::TYPE_FILE]);
+ $size = 0;
+ $mimetype = '';
+
+ if (is_array($content) && empty($content)) {
+ // do nothing, we don't need the body here
+ } else {
+ foreach ((array) $content as $idx => $chunk) {
+ $chunkId = \App\Utils::uuidStr();
+ $path = Storage::chunkLocation($chunkId, $file);
+
+ $disk->write($path, $chunk);
+
+ if (!$size) {
+ $mimetype = $disk->mimeType($path);
+ }
+
+ $size += strlen($chunk);
+
+ $file->chunks()->create([
+ 'chunk_id' => $chunkId,
+ 'sequence' => $idx,
+ 'size' => strlen($chunk),
+ ]);
+ }
+ }
+
+ $properties = [
+ 'name' => $name,
+ 'size' => $size,
+ 'mimetype' => $mimetype ?: 'application/octet-stream',
+ ];
+
+ $file->setProperties($props + $properties);
+
+ return $file;
+ }
+
+ /**
+ * Get contents of a test file.
+ *
+ * @param \App\Fs\Item $file File record
+ *
+ * @return string
+ */
+ protected function getTestFileContent(Item $file): string
+ {
+ $content = '';
+
+ $file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file, &$content) {
+ $disk = LaravelStorage::disk('files');
+ $path = Storage::chunkLocation($chunk->chunk_id, $file);
+
+ $content .= $disk->read($path);
+ });
+
+ return $content;
+ }
+
+ /**
+ * Create a test file permission.
+ *
+ * @param \App\Fs\Item $file The file
+ * @param \App\User $user File owner
+ * @param string $permission File permission
+ *
+ * @return \App\Fs\Property File permission property
+ */
+ protected function getTestFilePermission(Item $file, User $user, string $permission): Property
+ {
+ $shareId = 'share-' . \App\Utils::uuidStr();
+
+ return $file->properties()->create([
+ 'key' => $shareId,
+ 'value' => "{$user->email}:{$permission}",
+ ]);
+ }
+
+ /**
+ * Invoke a HTTP request with a custom raw body
+ *
+ * @param ?\App\User $user Authenticated user
+ * @param string $method Request method (POST, PUT)
+ * @param string $uri Request URL
+ * @param array $headers Request headers
+ * @param string $content Raw body content
+ *
+ * @return \Illuminate\Testing\TestResponse HTTP Response object
+ */
+ protected function sendRawBody(?User $user, string $method, string $uri, array $headers, string $content)
+ {
+ $headers['Content-Length'] = strlen($content);
+
+ $server = $this->transformHeadersToServerVars($headers);
+ $cookies = $this->prepareCookiesForRequest();
+
+ if ($user) {
+ return $this->actingAs($user)->call($method, $uri, [], $cookies, [], $server, $content);
+ } else {
+ // TODO: Make sure this does not use "acting user" set earlier
+ return $this->call($method, $uri, [], $cookies, [], $server, $content);
+ }
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 10:07 AM (7 h, 18 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828928
Default Alt Text
D3463.1775297224.diff (111 KB)
Attached To
Mode
D3463: [WIP] Files API
Attached
Detach File
Event Timeline