Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117971520
D3463.1775510437.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
78 KB
Referenced Files
None
Subscribers
None
D3463.1775510437.diff
View Options
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,373 @@
+<?php
+
+namespace App\Backends;
+
+use App\File;
+use App\Library;
+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\File $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileDelete(File $file): void
+ {
+ $disk = LaravelStorage::disk('files');
+
+ $path = $file->path . '/' . $file->id;
+
+ $disk->delete($path);
+ }
+
+ /**
+ * File download handler.
+ *
+ * @param \App\File $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileDownload(File $file): StreamedResponse
+ {
+ $disk = LaravelStorage::disk('files');
+
+ $path = $file->path . '/' . $file->id;
+
+ // I noticed that the Laravel's download() method can fail with an exception
+ // thrown by mimetype detector. To prevent that we do this with a custom code
+ // return LaravelStorage::download($path, $file->name, $headers);
+
+ $response = new StreamedResponse();
+
+ // Prepare the file name for the Content-Disposition header
+ $extension = pathinfo($file->name, \PATHINFO_EXTENSION) ?: 'file';
+ $fallbackName = str_replace('%', '', Str::ascii($file->name)) ?: "file.{$extension}";
+ $disposition = $response->headers->makeDisposition('attachment', $file->name, $fallbackName);
+
+ $response->headers->replace([
+ 'Content-Length' => $file->size,
+ 'Content-Type' => $file->mimetype,
+ 'Content-Disposition' => $disposition,
+ ]);
+
+ $response->setCallback(function () use ($disk, $path) {
+ $stream = $disk->readStream($path);
+ fpassthru($stream);
+ fclose($stream);
+ });
+
+ return $response;
+ }
+
+ /**
+ * File upload handler
+ *
+ * @param \App\Library $library The library to which the file belongs
+ * @param resource $stream File input stream
+ * @param array $params Request parameters
+ *
+ * @return array File/Response attributes
+ * @throws \Exception
+ */
+ public static function fileInput(Library $library, $stream, array $params): array
+ {
+ if (!empty($params['uploadId'])) {
+ return self::fileInputResumable($library, $stream, $params);
+ }
+
+ $disk = LaravelStorage::disk('files');
+
+ $file = $library->files()->create([
+ 'mimetype' => File::TYPE_INCOMPLETE,
+ 'name' => $params['name'],
+ ]);
+
+ $path = $file->path . '/' . $file->id;
+
+ $disk->writeStream($path, $stream);
+
+ // Update the file type and size information
+ $file->size = $disk->fileSize($path);
+ $file->mimetype = self::mimetype($path);
+ $file->save();
+
+ return $file->toArray();
+ }
+
+ /**
+ * Resumable file upload handler
+ *
+ * @param \App\Library $library The library to which the file belongs
+ * @param resource $stream File input stream
+ * @param array $params Request parameters
+ *
+ * @return array File/Response attributes
+ * @throws \Exception
+ */
+ protected static function fileInputResumable(Library $library, $stream, array $params): array
+ {
+ $init = $params['uploadId'] == 'resumable';
+
+ if ($init) {
+ if (empty($params['size'])) {
+ // error
+ }
+
+ $file = $library->files()->create([
+ 'mimetype' => File::TYPE_INCOMPLETE,
+ 'name' => $params['name'],
+ ]);
+
+ $params['uploadId'] = \App\Utils::uuidStr();
+
+ $upload = [
+ 'fileId' => $file->id,
+ 'size' => $params['size'],
+ 'uploaded' => 0,
+ 'libraryId' => $library->id,
+ ];
+
+ if (!Cache::add('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL)) {
+ // error
+ }
+
+ // Empty initial request? return early
+ $byte = fread($stream, 1);
+ if ($byte === false || $byte === '') {
+ return ['uploadId' => $params['uploadId'], 'uploaded' => 0];
+ }
+
+ rewind($stream);
+ } else {
+ $upload = Cache::get('upload:' . $params['uploadId']);
+
+ if (empty($upload)) {
+ // error
+ }
+
+ $file = File::find($upload['fileId']);
+
+ if (!$file) {
+ // error
+ }
+ }
+
+ $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']) {
+ // error
+ } elseif ($library->id != $upload['libraryId']) {
+ // error
+ }
+
+ $disk = LaravelStorage::disk('files');
+
+ $path = 'tmp/' . $params['uploadId'] . '/' . sprintf('%010d', $from);
+
+ // Save the file chunk
+ $disk->writeStream($path, $stream);
+
+ $fsize = $disk->fileSize($path);
+
+ // Update the file type and size information after the upload of the whole
+ // file is completed
+ if ($fsize + $upload['uploaded'] >= $upload['size']) {
+ // Put uploaded chunks together
+ $files = [];
+ foreach ($disk->listContents('tmp/' . $params['uploadId']) as $fileAttr) {
+ $files[] = $fileAttr->path();
+ }
+
+ $target = $file->path . '/' . $file->id;
+
+ if (count($files) == 1) {
+ $disk->move($files[0], $target);
+ } else {
+ sort($files);
+ self::concatenate($target, $files);
+ }
+
+ // Update file metadata
+ $file->size = $disk->fileSize($target);
+ $file->mimetype = self::mimetype($target);
+ $file->save();
+
+ // Delete the upload cache record
+ Cache::forget('upload:' . $params['uploadId']);
+
+ // TODO: There should be a job that removes orphaned files/folders from the temp folder anyway
+ // So we could ignore that here to make the response faster
+ $disk->deleteDirectory('tmp/' . $params['uploadId']);
+
+ return $file->toArray();
+ }
+
+ $upload['uploaded'] += $fsize;
+
+ Cache::put('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL);
+
+ return ['uploadId' => $params['uploadId'], 'uploaded' => $upload['uploaded']];
+ }
+
+ /**
+ * Concatenate multiple files into a target location
+ *
+ * @param string $target Target file location
+ * @param array $files Source files
+ *
+ * @throws \Exception
+ */
+ protected static function concatenate(string $target, array $files): void
+ {
+ $disk = LaravelStorage::disk('files');
+
+ // If the target file exists, rename it to <name>.backup
+ if ($disk->fileExists($target)) {
+ $disk->move($target, $backup = "{$target}.backup");
+ }
+
+ try {
+ $streams = [];
+ foreach ($files as $file) {
+ $streams[] = $disk->readStream($file);
+ }
+
+ $stream = self::getResource($streams);
+ $disk->writeStream($target, $stream);
+
+ fclose($stream);
+ foreach ($streams as $stream) {
+ @fclose($stream);
+ }
+
+ if (!empty($backup)) {
+ $disk->delete($backup);
+ }
+ } catch (\Exception $e) {
+ // On error rename the backup file back to the original
+ if (!empty($backup)) {
+ $disk->move($backup, $target);
+ }
+
+ throw $e;
+ }
+ }
+
+ /**
+ * 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';
+ }
+
+ /**
+ * Create a stream containing concatenated content of multiple streams
+ *
+ * @param array<resource> $streams Resource streams
+ * @param int $chunkSize Chunk size for copying operation
+ *
+ * @return resource
+ */
+ protected static function getResource($streams, int $chunkSize = 8192)
+ {
+ if (empty($streams)) {
+ return fopen('data://text/plain,','r');
+ }
+
+ if (count($streams) == 1) {
+ return reset($streams);
+ }
+
+ // Write all input streams data into one
+ // Note: The temp file will be created only if there's not enough memory.
+ // We can use php://temp/memory:XX if we wanted to set the limit here
+ $output = fopen('php://temp','r+');
+
+ foreach ($streams as $stream) {
+ stream_copy_to_stream($stream, $output);
+ }
+
+ rewind($output);
+
+ // TODO: Find why the filter-based approach below does not work
+ // TODO: Find another approach. Probably depending on the storage driver
+ // there will be better API to do the append operation faster.
+ // For example in filesystem we could just execute cat command.
+ // AWS/S3-type APIs might have also ways to do this better.
+
+ return $output;
+/*
+ // Note: The code below copied from keven/append-stream is working, but
+ // has the memory limit issue, even though it's supposed to have not
+
+ $head = tmpfile();
+ fwrite($head, fread($streams[0], 8192));
+ rewind($head);
+
+ $anonymous = new class($streams, $chunkSize) extends \php_user_filter
+ {
+ private static $streams = [];
+ private static $maxLength;
+
+ public function __construct(array $streams = [], int $maxLength = 8192)
+ {
+ self::$streams = $streams;
+ self::$maxLength = $maxLength;
+ }
+
+ public function filter($in, $out, &$consumed, $closing): int
+ {
+ while ($bucket = stream_bucket_make_writeable($in)) {
+ stream_bucket_append($out, $bucket);
+ }
+
+ foreach (self::$streams as $idx => $stream) {
+ while (feof($stream) !== true) {
+ $bucket = stream_bucket_new($stream, fread($stream, self::$maxLength));
+ stream_bucket_append($out, $bucket);
+ }
+ }
+
+ return PSFS_PASS_ON;
+ }
+ };
+
+ $filter = bin2hex(random_bytes(32));
+
+ stream_filter_register($filter, get_class($anonymous));
+ stream_filter_append($head, $filter);
+
+ return $head;
+*/
+ }
+}
diff --git a/src/app/File.php b/src/app/File.php
new file mode 100644
--- /dev/null
+++ b/src/app/File.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace App;
+
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a File.
+ *
+ * @property string $id
+ * @property string $library_id
+ * @property string $mimetype
+ * @property string $name
+ * @property string $path
+ * @property int $size
+ */
+class File extends Model
+{
+ use UuidStrKeyTrait;
+
+ public const TYPE_INCOMPLETE = 'application/x-incomplete-file';
+ public const TYPE_FOLDER = 'application/x-folder';
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'library_id',
+ 'mimetype',
+ 'name',
+ 'size',
+ ];
+
+ /** @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',
+ ];
+
+ /**
+ * Check whether specified user (email) has permission to the file
+ *
+ * @param string $user Username (email)
+ * @param int $perms Permissions
+ *
+ * @return bool
+ */
+ public function hasPermission(string $user, int $perms)
+ {
+ // FIXME: Should this method also check the file owner?
+
+ $permission = $this->permissions()->where('user', $user)->first();
+
+ return (bool) ($permission && ($permission->permissions & $perms));
+ }
+
+ /**
+ * The library to which this file belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function library()
+ {
+ return $this->belongsTo(Library::class, 'library_id', 'id');
+ }
+
+ /**
+ * Interact with the file's mimetype.
+ *
+ * @return \Illuminate\Database\Eloquent\Casts\Attribute
+ */
+ protected function mimetype(): Attribute
+ {
+ return Attribute::make(
+ // get: fn ($value) => \strtolower($value),
+ set: function ($value) {
+ return \strtolower($value);
+ },
+ );
+ }
+
+ /**
+ * 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->library_id) || empty($this->id)) {
+ throw new \Exception("No file ID or library ID");
+ }
+
+ $library_id = substr(hash('crc32b', $this->library_id), 0, 6);
+ $id = substr(hash('crc32b', $this->id), 0, 6);
+
+ return implode('/', str_split($library_id, 2))
+ . '/' . $this->library_id
+ . '/' . implode('/', str_split($id, 2));
+ }
+ );
+ }
+
+ /**
+ * Sharing permissions assigned to this file.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function permissions()
+ {
+ return $this->hasMany(FilePermission::class);
+ }
+}
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,380 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Backends\Storage;
+use App\Http\Controllers\RelationController;
+use App\File;
+use App\FilePermission;
+use App\Library;
+use App\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+
+class FilesController extends RelationController
+{
+ /** @var string Resource localization label */
+ protected $label = 'file';
+
+ /** @var string Resource model name */
+ protected $model = File::class;
+
+ /** @var array Common object properties in the API response */
+ protected $objectProps = ['mimetype', 'name', 'size'];
+
+ /**
+ * Delete a file.
+ *
+ * @param string $id File identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $file = File::find($id);
+
+ if (!$file) {
+ return $this->errorResponse(404);
+ }
+
+ if (!self::hasPermission($file, $this->guard()->user(), FilePermission::DELETE)) {
+ return $this->errorResponse(403);
+ }
+
+ // 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 the permissions for the specific file.
+ *
+ * @param string $id The file identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function getPermissions($id)
+ {
+ $file = File::find($id);
+
+ if (!$file) {
+ return $this->errorResponse(404);
+ }
+
+ // Only the folder owner can do that, for now
+ if ($this->guard()->user()->id != $file->library->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ $result = $file->permissions()->orderBy('user')->get()->map(
+ function ($permission) {
+ // FIXME: Here we map internal format to the one supported
+ // by the ACL widget, but I guess we can get rid of this limitation
+ // in the future, if needed.
+ if ($permission->permissions & FilePermission::DELETE) {
+ $perms = 'full';
+ } elseif ($permission->permissions & FilePermission::WRITE) {
+ $perms = 'read-write';
+ }
+
+ return [
+ 'user' => $permission->user,
+ 'permissions' => $perms ?? 'read-only',
+ ];
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * 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();
+
+ if (request()->input('shared')) {
+ $result = File::join('file_permissions', 'file_permissions.file_id', '=', 'files.id')
+ ->where('user', $user->email);
+ } else {
+ $library = $this->defaultLibrary($user);
+
+ $result = $library->files();
+ }
+
+ $result = $result->where('mimetype', '<>', File::TYPE_INCOMPLETE)
+ ->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) {
+ return $this->objectToClient($file);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set permissions for the specific file.
+ *
+ * @param string $id The file identifier.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function setPermissions($id)
+ {
+ $file = File::find($id);
+
+ if (!$file) {
+ return $this->errorResponse(404);
+ }
+
+ // Only the library owner can do that, for now
+ if ($this->guard()->user()->id != $file->library->user_id) {
+ return $this->errorResponse(403);
+ }
+
+ // Validate/format input
+ $input = [];
+ $errors = [];
+
+ foreach ((array) request()->input('permissions') as $idx => $entry) {
+ $acl = $entry['permissions'] ?? '';
+ $user = $entry['user'] ?? '';
+
+ if (empty($user)) {
+ continue;
+ }
+
+ // validate user email
+ $v = Validator::make(['email' => $user], ['email' => 'email']);
+
+ if ($v->fails()) {
+ $errors['user'][$idx] = \trans('validation.emailinvalid');
+ }
+
+ // The ACL widget supports 'full', 'read-write', 'read-only', convert
+ // it to the internal format
+ if ($acl == 'full') {
+ $acl = FilePermission::DELETE | FilePermission::WRITE | FilePermission::READ;
+ } elseif ($acl == 'read-write') {
+ $acl = FilePermission::WRITE | FilePermission::READ;
+ } elseif ($acl == 'read-only') {
+ $acl = FilePermission::READ;
+ } else {
+ continue;
+ }
+
+ $input[$user] = $acl;
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ // Get existing permissions and compare with the new ones
+ // Update/delete existing entries
+ $file->permissions()->get()->each(function($permission) use (&$input) {
+ if (!empty($input[$permission->user])) {
+ $permission->permissions = $input[$permission->user];
+ $permission->save();
+
+ unset($input[$permission->user]);
+ } else {
+ $permission->delete();
+ }
+ });
+
+ // Create new permissions
+ foreach ($input as $user => $permissions) {
+ $file->permissions()->create([
+ 'user' => $user,
+ 'permissions' => $permissions
+ ]);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.file-permissions-update-success'),
+ ]);
+ }
+
+ /**
+ * 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 = File::find($id);
+
+ if (!$file) {
+ return $this->errorResponse(404);
+ }
+
+ if (!self::hasPermission($file, $this->guard()->user(), FilePermission::READ)) {
+ return $this->errorResponse(403);
+ }
+
+ if (request()->input('download')) {
+ return Storage::fileDownload($file);
+ }
+
+ $response = $file->toArray();
+
+ 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();
+
+ $library = $this->defaultLibrary($user);
+
+ $filename = $request->input('name');
+
+ // TODO: Validate file name input
+ // TODO: Delete the existing incomplete file with the same name?
+
+ // 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 = ['name' => $filename];
+
+ if ($upload = $request->input('upload')) {
+ $params['uploadId'] = $upload;
+ $params['size'] = $request->input('size');
+ $params['from'] = $request->input('from');
+ }
+
+ try {
+ $response = Storage::fileInput($library, $request->getContent(true), $params);
+
+ $response['status'] = 'success';
+
+ if (!empty($response['id'])) {
+ $response['message'] = \trans('app.file-create-success');
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ 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 = File::find($id);
+
+ if (empty($file)) {
+ return $this->errorResponse(404);
+ }
+
+ $user = $this->guard()->user();
+
+ if (!self::hasPermission($file, $user, FilePermission::WRITE)) {
+ return $this->errorResponse(403);
+ }
+
+ // TODO
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.file-update-success'),
+ ]);
+ }
+
+ /**
+ * Get the user's default Library. Create one if it does not exist.
+ *
+ * @param \App\User $user User
+ *
+ * @return \App\Library The default library for the user
+ */
+ protected function defaultLibrary(User $user): Library
+ {
+ $library = $user->libraries()->first();
+
+ if (!$library) {
+ $library = $user->libraries()->create(['name' => null]);
+ }
+
+ return $library;
+ }
+
+ /**
+ * Checks whether the specified user has specified permissions to a file.
+ *
+ * @param \App\File $file The file
+ * @param \App\User $user Current user
+ * @param int $perms Permissions
+ *
+ * @return bool
+ */
+ protected static function hasPermission(File $file, User $user, int $perms)
+ {
+ // File owner
+ if ($user->id == $file->library->user_id) {
+ return true;
+ }
+
+ return $file->hasPermission($user->email, $perms);
+ }
+}
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' => true,
// 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/Library.php b/src/app/Library.php
new file mode 100644
--- /dev/null
+++ b/src/app/Library.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace App;
+
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a library.
+ *
+ * A library is owned by an {@link \App\User}.
+ *
+ * @property string $id Unique identifier
+ * @property string $name Name
+ * @property int $user_id Owner's identifier
+ */
+class Library extends Model
+{
+ use UuidStrKeyTrait;
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'name',
+ 'user_id',
+ ];
+
+ /** @var array<int, string> The attributes that can be not set */
+ protected $nullable = [
+ 'name',
+ ];
+
+
+ /**
+ * Files in this library.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function files()
+ {
+ return $this->hasMany(File::class);
+ }
+
+ /**
+ * The user the library belongs to.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id', 'id');
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -381,6 +381,16 @@
return false;
}
+ /**
+ * Storage libraries for this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function libraries()
+ {
+ return $this->hasMany(Library::class);
+ }
+
/**
* A shortcut to get the user name.
*
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/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,75 @@
+<?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(
+ 'libraries',
+ function (Blueprint $table) {
+ $table->string('id', 36)->primary();
+ $table->bigInteger('user_id');
+ $table->string('name')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ Schema::create(
+ 'files',
+ function (Blueprint $table) {
+ $table->string('id', 36)->primary();
+ $table->string('library_id', 36);
+ $table->string('name', 512);
+ $table->bigInteger('size')->unsigned()->default(0);
+ $table->string('mimetype');
+ // $table->text('metadata')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->unique(['library_id', 'name']);
+
+ $table->foreign('library_id')->references('id')->on('libraries')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ Schema::create(
+ 'file_permissions',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('file_id', 36);
+ // FIXME: Maybe instead of 'user', it would be better to name it 'who', or 'identifier' or?
+ $table->string('user')->index();
+ $table->smallInteger('permissions')->unsigned()->default(0);
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->unique(['file_id', 'user']);
+
+ $table->foreign('file_id')->references('id')->on('files')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('file_permissions');
+ Schema::dropIfExists('files');
+ Schema::dropIfExists('libraries');
+ }
+};
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
@@ -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,153 @@
+
+function FileAPI(params = {})
+{
+ // 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.
+ const maxChunkSize = params.maxChunkSize || 10 * 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
+ },
+ 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 body = file
+
+ if (file.size > maxChunkSize) {
+ body = file.slice(start, start + maxChunkSize, file.type)
+
+ if (uploadId) {
+ config.params.upload = uploadId
+ config.params.from = start
+ } else {
+ config.params.upload = 'resumable'
+ config.params.size = file.size
+ }
+ }
+
+ start += maxChunkSize
+
+ axios.post('api/v4/files', body, config)
+ .then(response => {
+ if (start < file.size) {
+ file.uploaded = start
+ uploadFn(start, response.data.uploadId)
+ } else {
+ progress.completed = 100
+ params.eventHandler('upload-progress', progress)
+ }
+ })
+ .catch(error => {
+ // TODO: The process might get stopped if the authentication token expires
+ // in the middle of the upload process, we have to detect 401 response,
+ // refresh the token and continue with the last chunk that failed.
+ // Related setting: OAUTH_TOKEN_EXPIRY
+
+ // console.error(error)
+ progress.error = error
+ progress.completed = 100
+ params.eventHandler('upload-progress', progress)
+ })
+ }
+
+ // Start uploading
+ uploadFn()
+ }
+ }
+
+ /**
+ * 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
@@ -13,6 +13,7 @@
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 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')
@@ -57,6 +58,12 @@
component: DomainListComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
+ {
+ path: '/files',
+ name: 'files',
+ component: FileListComponent,
+ meta: { requiresAuth: true, perm: 'files' }
+ },
{
path: '/login',
name: 'login',
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
@@ -68,6 +68,11 @@
'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-update-success' => 'File permissions updated 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",
@@ -43,6 +44,7 @@
'distlists' => "Distribution lists",
'chat' => "Video chat",
'domains' => "Domains",
+ 'files' => "Files",
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
@@ -109,6 +111,16 @@
'form' => "Form validation error",
],
+ 'file' => [
+ 'create' => "Create file",
+ 'delete' => "Delete file",
+ 'list-empty' => "There are no files in this account.",
+ 'new' => "New file",
+ 'search' => "File name",
+ 'sharing' => "File sharing",
+ 'sharing-text' => "",
+ ],
+
'form' => [
'acl' => "Access rights",
'acl-full' => "All",
@@ -137,6 +149,7 @@
'phone' => "Phone",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
+ 'size' => "Size",
'status' => "Status",
'surname' => "Surname",
'type' => "Type",
@@ -285,6 +298,7 @@
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
+ 'uploading' => "Uploading...",
'warning' => "Warning",
'success' => "Success",
],
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="!$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/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,268 @@
+<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">
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-library" href="#library" role="tab" aria-controls="library" aria-selected="true" @click="$root.tab">
+ Your library
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-shared" href="#shared" role="tab" aria-controls="shared" aria-selected="false">
+ Shared with you
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="library" role="tabpanel" aria-labelledby="tab-library">
+ <div class="mb-2 mt-3 d-flex">
+ <list-search :placeholder="$t('file.search')" :on-search="searchUsers"></list-search>
+ </div>
+ <table class="table table-sm table-hover">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.size') }}</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="file in files" :key="file.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="file" :title="file.mimetype"></svg-icon>
+ {{ file.name }}
+ </td>
+ <td>
+ {{ api.sizeText(file.size) }}
+ </td>
+ <td class="buttons">
+ <btn class="button-share p-0 ms-1" @click="fileShare(file)" icon="share-alt-square" :title="$t('btn.share')"></btn>
+ <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="3" :text="$t('file.list-empty')"></list-foot>
+ </table>
+ <list-more v-if="hasMore" :on-click="loadFiles"></list-more>
+ </div>
+ <div class="tab-pane" id="shared" role="tabpanel" aria-labelledby="tab-shared">
+ <div class="mb-2 mt-3 d-flex">
+ <list-search :placeholder="$t('file.search')" :on-search="searchShares"></list-search>
+ </div>
+ <table class="table table-sm table-hover">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.size') }}</th>
+ <th scope="col"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="file in shares" :key="file.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="file" :title="file.mimetype"></svg-icon>
+ {{ file.name }}
+ </td>
+ <td>
+ {{ api.sizeText(file.size) }}
+ </td>
+ <td class="buttons">
+ <btn class="button-download p-0 ms-1" @click="fileDownload(file)" icon="download" :title="$t('btn.download')"></btn>
+ <btn v-if="false" 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="3" :text="$t('file.list-empty')"></list-foot>
+ </table>
+ <list-more v-if="hasMore" :on-click="loadShares"></list-more>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="share-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ $t('file.sharing') }}</h5>
+ <btn class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></btn>
+ </div>
+ <div class="modal-body">
+ <p>{{ $t('file.sharing-text') }}</p>
+ <acl-input id="acl" v-model="acl" :list="acl" :useronly="true" class="mb-1"></acl-input>
+ </div>
+ <div class="modal-footer">
+ <btn class="btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</btn>
+ <btn class="btn-primary modal-action" icon="check" @click="submitShares()">{{ $t('btn.submit') }}</btn>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import { Modal } from 'bootstrap'
+ import FileAPI from '../../js/files.js'
+ import AclInput from '../Widgets/AclInput'
+ import ListTools from '../Widgets/ListTools'
+
+ import { library } from '@fortawesome/fontawesome-svg-core'
+ import { faFile, faDownload, faShareAltSquare, faUpload } from '@fortawesome/free-solid-svg-icons'
+
+ library.add(faFile, faDownload, faShareAltSquare, faUpload)
+
+ export default {
+ components: {
+ AclInput
+ },
+ mixins: [ ListTools ],
+ data() {
+ return {
+ acl: [],
+ api: {},
+ file: null,
+ files: [],
+ shares: []
+ }
+ },
+ mounted() {
+ this.uploads = {}
+
+ this.api = new FileAPI({
+ dropArea: '#drop-area',
+ eventHandler: this.eventHandler
+ })
+
+ this.loadFiles({ init: true })
+
+ $('#tab-shared').on('click', e => {
+ this.$root.tab(e)
+ this.loadShares({ 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) {
+ // FIXME: This is not an appropriate method for big files.
+ // We need to implement something like a "unique download url"
+ // which then would be used to download a file without
+ // a need for the api authentication headers.
+
+ this.$root.downloadFile('/api/v4/files/' + file.id + '?download=1', file.name)
+ },
+ fileShare(file) {
+ this.acl = []
+ this.file = file
+
+ // Display the dialog
+ this.dialog = new Modal('#share-dialog')
+ this.dialog.show()
+
+ const form = $('#share-dialog .modal-body')
+
+ this.$root.addLoader(form)
+
+ axios.get('/api/v4/files/' + file.id + '/permissions')
+ .then(response => {
+ this.$root.removeLoader(form)
+ if (response.data.list) {
+ response.data.list.forEach(item => {
+ this.acl.push(`${item.user}, ${item.permissions}`)
+ })
+ }
+ })
+ .catch(error => {
+ this.$root.removeLoader(form)
+ })
+ },
+ loadFiles(params, shared) {
+ this.listSearch('files', '/api/v4/files', params)
+ },
+ loadShares(params) {
+ if (!params) params = {}
+ params.get = { shared: 1 }
+ this.listSearch('shares', '/api/v4/files', params)
+ },
+ searchFiles(search) {
+ this.loadFiles({ reset: true, search })
+ },
+ searchShares(search) {
+ this.loadShares({ reset: true, search })
+ },
+ submitShares() {
+ const post = { permissions: [] }
+
+ // acl's widget outout is different that we need here
+ this.acl.forEach(item => {
+ const entry = item.split(', ')
+ post.permissions.push({
+ user: entry[0],
+ permissions: entry[1],
+ })
+ })
+
+ axios.post('/api/v4/files/' + this.file.id + '/permissions', post)
+ .then(response => {
+ this.dialog.hide();
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ }
+ })
+ },
+ 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
@@ -76,6 +76,10 @@
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/{id}/permissions', [API\V4\FilesController::class, 'getPermissions']);
+ Route::post('files/{id}/permissions', [API\V4\FilesController::class, 'setPermissions']);
+
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,441 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\File;
+use App\FilePermission;
+use App\Library;
+use App\User;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class FilesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ File::query()->delete();
+ Library::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ File::query()->delete();
+ Library::query()->delete();
+
+ $disk = Storage::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
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test fetching/creating/updaing file permissions (GET|POST /api/v4/files/<file-id>/permissions)
+ */
+ public function testPermissions(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $library = $john->libraries()->create(['name' => null]);
+ $file = $library->files()->create([
+ 'mimetype' => 'text/plain',
+ 'name' => 'test1.txt',
+ 'size' => 12345,
+ ]);
+
+ // 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(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions updated successfully.", $json['message']);
+
+ // TODO: Test input validation
+
+ // Let's set some permissions
+ $post = [
+ 'permissions' => [
+ ['user' => 'test@gmail.com', 'permissions' => 'read-only'],
+ ['user' => 'jack@kolab.org', 'permissions' => 'read-write'],
+ ],
+ ];
+
+ $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 updated successfully.", $json['message']);
+
+ $permissions = $file->permissions()->orderBy('user')->get();
+
+ $this->assertCount(2, $permissions);
+ $this->assertSame(FilePermission::WRITE | FilePermission::READ, $permissions[0]->permissions);
+ $this->assertSame($jack->email, $permissions[0]->user);
+ $this->assertSame(FilePermission::READ, $permissions[1]->permissions);
+ $this->assertSame('test@gmail.com', $permissions[1]->user);
+
+ // Test update/delete entries
+ $post = [
+ 'permissions' => [
+ ['user' => 'jack@kolab.org', 'permissions' => 'read-only'],
+ ['user' => 'test1@gmail.com', 'permissions' => 'read-write'],
+ ['user' => 'test2@kolab.org', 'permissions' => 'full'],
+ ],
+ ];
+
+ $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 updated successfully.", $json['message']);
+
+ $permissions = $file->permissions()->orderBy('user')->get();
+
+ $this->assertCount(3, $permissions);
+ $this->assertSame(FilePermission::READ, $permissions[0]->permissions);
+ $this->assertSame($jack->email, $permissions[0]->user);
+ $this->assertSame(FilePermission::WRITE | FilePermission::READ, $permissions[1]->permissions);
+ $this->assertSame('test1@gmail.com', $permissions[1]->user);
+ $this->assertSame(
+ FilePermission::WRITE | FilePermission::READ | FilePermission::DELETE,
+ $permissions[2]->permissions
+ );
+ $this->assertSame('test2@kolab.org', $permissions[2]->user);
+
+ // 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->assertSame($post['permissions'], $json['list']);
+ $this->assertSame(3, $json['count']);
+ }
+
+ /**
+ * Test fetching files/folders list
+ */
+ 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
+ $library = $user->libraries()->create(['name' => null]);
+ $file1 = $library->files()->create([
+ 'mimetype' => 'text/plain',
+ 'name' => 'test1.txt',
+ 'size' => 12345,
+ ]);
+ $file2 = $library->files()->create([
+ 'mimetype' => 'image/gif',
+ 'name' => 'test3.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($file1->name, $json['list'][0]['name']);
+ $this->assertSame($file1->id, $json['list'][0]['id']);
+ $this->assertSame($file1->mimetype, $json['list'][0]['mimetype']);
+ $this->assertSame($file1->size, $json['list'][0]['size']);
+ $this->assertSame($file2->name, $json['list'][1]['name']);
+ $this->assertSame($file2->id, $json['list'][1]['id']);
+ $this->assertSame($file2->mimetype, $json['list'][1]['mimetype']);
+ $this->assertSame($file2->size, $json['list'][1]['size']);
+
+ // TODO: Test paging
+ }
+
+ /**
+ * Test fetching file metadata/content (GET /api/v4/files/<file-id>)
+ */
+ public function testShow(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/files/1234");
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $file = $this->getTestFile($user, '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($user)->get("api/v4/files/{$file->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $this->assertSame($file->library_id, $json['library_id']);
+ $this->assertSame($file->mimetype, $json['mimetype']);
+ $this->assertSame($file->size, $json['size']);
+
+ // Get file content
+ $response = $this->actingAs($user)->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->size)
+ ->assertHeader('Content-Type', $file->mimetype . '; charset=UTF-8');
+
+ $this->assertSame('Teśt content', $response->streamedContent());
+
+ // TODO: Test acting as a user with file permissions
+ }
+
+ /**
+ * Test creating files (POST /api/v4/files)
+ */
+ public function testStore(): void
+ {
+ // Unauth access not allowed
+ $response = $this->post("api/v4/files");
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ // TODO: Test name validation
+/*
+ $response = $this->sendRawBody($user, 'POST', "api/v4/files", [], '');
+ $response = $this->actingAs($user)->post("api/v4/files", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ // $this->assertCount(3, $json);
+*/
+ // Create a file - the simple method
+ $body = "test content";
+ $headers = [];
+ $response = $this->sendRawBody($user, '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 = File::find($json['id']);
+
+ $this->assertSame($file->mimetype, $json['mimetype']);
+ $this->assertSame($file->library_id, $json['library_id']);
+ $this->assertSame($json['size'], $file->size);
+ $this->assertSame('test.txt', $file->name);
+
+ $disk = Storage::disk('files');
+ $path = $file->path . '/' . $file->id;
+
+ $this->assertSame($body, $disk->read($path));
+
+ // TODO: Test acting as another user with/without file permissions
+ }
+
+ /**
+ * Test creating files - resumable (POST /api/v4/files)
+ */
+ public function testStoreResumable(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+
+ $response = $this->actingAs($user)->post("api/v4/files?name=test2.txt&upload=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($user, 'POST', "api/v4/files?upload={$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($user, 'POST', "api/v4/files?upload={$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 = File::find($json['id']);
+
+ $this->assertSame($json['mimetype'], $file->mimetype);
+ $this->assertSame($json['library_id'], $file->library_id);
+ $this->assertSame($size, $file->size);
+ $this->assertSame('test2.txt', $file->name);
+
+ $disk = Storage::disk('files');
+ $path = $file->path . '/' . $file->id;
+
+ $this->assertSame($fileContent, $disk->read($path));
+ }
+
+ /**
+ * Test updating files (PUT /api/v4/files/<file-id>)
+ */
+ public function testUpdate(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Create a test file.
+ *
+ * @param \App\User $user File owner
+ * @param string $name File name
+ * @param string $content File content
+ */
+ protected function getTestFile(User $user, string $name, string $content = '')
+ {
+ $library = $user->libraries()->first();
+
+ if (!$library) {
+ $library = $user->libraries()->create(['name' => null]);
+ }
+
+ $disk = Storage::disk('files');
+
+ $file = $library->files()->create([
+ 'mimetype' => File::TYPE_INCOMPLETE,
+ 'name' => $name,
+ ]);
+
+ $path = $file->path . '/' . $file->id;
+
+ $disk->write($path, $content);
+
+ $file->size = $disk->fileSize($path);
+ $file->mimetype = $disk->mimeType($path) ?: 'application/octet-stream';
+ $file->save();
+
+ return $file;
+ }
+
+ /**
+ * 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();
+
+ return $this->actingAs($user)->call($method, $uri, [], $cookies, [], $server, $content);
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Apr 6, 9:20 PM (13 h, 48 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833334
Default Alt Text
D3463.1775510437.diff (78 KB)
Attached To
Mode
D3463: [WIP] Files API
Attached
Detach File
Event Timeline