Page MenuHomePhorge

D3463.1775297224.diff
No OneTemporary

Authored By
Unknown
Size
111 KB
Referenced Files
None
Subscribers
None

D3463.1775297224.diff

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

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)

Event Timeline