Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117915329
D3463.1775405869.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
106 KB
Referenced Files
None
Subscribers
None
D3463.1775405869.diff
View Options
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -114,6 +114,7 @@
REDIS_PASSWORD=null
REDIS_PORT=6379
+SWOOLE_PACKAGE_MAX_LENGTH=10485760
SWOOLE_HOT_RELOAD_ENABLE=true
SWOOLE_HTTP_ACCESS_LOG=true
SWOOLE_HTTP_HOST=127.0.0.1
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,254 @@
+<?php
+
+namespace App\Backends;
+
+use App\Fs\File;
+use App\Fs\Node;
+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\File $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileDelete(File $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->nodes()->delete();
+ }
+
+ /**
+ * File download handler.
+ *
+ * @param \App\Fs\File $file File object
+ *
+ * @throws \Exception
+ */
+ public static function fileDownload(File $file): StreamedResponse
+ {
+ $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 ($file) {
+ $file->nodes()->orderBy('id')->get()->each(function ($node) use ($file) {
+ $disk = LaravelStorage::disk('files');
+ $path = Storage::nodeLocation($node, $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\File $file The file object
+ *
+ * @return array File/Response attributes
+ * @throws \Exception
+ */
+ public static function fileInput($stream, array $params, File $file = null): array
+ {
+ if (!empty($params['uploadId'])) {
+ return self::fileInputResumable($stream, $params, $file);
+ }
+
+ $disk = LaravelStorage::disk('files');
+
+ // Note: We set deleted_at to indicate the node is not active yet
+ $node = $file->nodes()->create(['deleted_at' => \now()]);
+
+ $path = self::nodeLocation($node, $file);
+
+ $disk->writeStream($path, $stream);
+
+ // Update the file type and size information
+ $file->size = $disk->fileSize($path);
+ $file->mimetype = self::mimetype($path);
+ $file->save();
+
+ // Assign the node to the file, "unlink" any old nodes of this file
+ $file->nodes()->delete();
+ $node->restore();
+
+ return $file->toArray();
+ }
+
+ /**
+ * Resumable file upload handler
+ *
+ * @param resource $stream File input stream
+ * @param array $params Request parameters
+ * @param ?\App\Fs\File $file The file object
+ *
+ * @return array File/Response attributes
+ * @throws \Exception
+ */
+ protected static function fileInputResumable($stream, array $params, File $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 = File::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');
+
+ // Note: We set deleted_at to indicate the node is not active yet
+ $node = $file->nodes()->create(['deleted_at' => \now()]);
+
+ $path = self::nodeLocation($node, $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['nodes'] = [];
+ }
+
+ $upload['nodes'][] = $node->id;
+ $upload['uploaded'] += $disk->fileSize($path);
+
+ // Update the file metadata after the upload of all chunks is completed
+ if ($upload['uploaded'] >= $upload['size']) {
+ // Update file metadata
+ $file->size = $upload['uploaded'];
+ $file->mimetype = $upload['mimetype'] ?: 'application/octet-stream';
+ $file->save();
+
+ // Assign uploaded chunks to the file, "unlink" any old chunks of this file
+ $file->nodes()->delete();
+ $file->nodes()->whereIn('id', $upload['nodes'])->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 $file->toArray();
+ }
+
+ // 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 \App\Fs\Node $node File chunk
+ * @param ?\App\Fs\File $file File the chunk belongs to
+ *
+ * @return string Node location
+ */
+ public static function nodeLocation(Node $node, ?File $file = null): string
+ {
+ if (!$file) {
+ $file = $node->file;
+ }
+
+ return sprintf('%s/%s/%012d', $file->path, $file->id, $node->id);
+ }
+}
diff --git a/src/app/Fs/File.php b/src/app/Fs/File.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/File.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace App\Fs;
+
+use App\User;
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a File.
+ *
+ * @property string $id File identifier
+ * @property string $mimetype File content type
+ * @property string $name File name
+ * @property string $path File path (readonly)
+ * @property int $size File size (in bytes)
+ * @property int $user_id File owner
+ */
+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 = [
+ 'user_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',
+ ];
+
+ /** @var string Database table name */
+ protected $table = 'fs_files';
+
+
+ /**
+ * 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->id)) {
+ throw new \Exception("Cannot get path for a file without ID");
+ }
+
+ $id = substr($this->id, 0, 6);
+
+ return implode('/', str_split($id, 2));
+ }
+ );
+ }
+
+ /**
+ * FIle parts (nodes) of this file.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function nodes()
+ {
+ return $this->hasMany(Node::class);
+ }
+
+ /**
+ * Sharing permissions assigned to this file.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function permissions()
+ {
+ return $this->hasMany(Permission::class);
+ }
+
+ /**
+ * The user to which this file belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id', 'id');
+ }
+}
diff --git a/src/app/Fs/Node.php b/src/app/Fs/Node.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Node.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Fs;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * The eloquent definition of a file chunk.
+ *
+ * @property int $id Node identifier
+ * @property ?string $file_id File identifier
+ * @property string $path Node location
+ */
+class Node extends Model
+{
+ use SoftDeletes;
+
+ public $timestamps = false;
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = ['file_id', 'deleted_at'];
+
+ /** @var string Database table name */
+ protected $table = 'fs_nodes';
+
+ /**
+ * The file the node belongs to.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function file()
+ {
+ return $this->belongsTo(File::class);
+ }
+}
diff --git a/src/app/Fs/Permission.php b/src/app/Fs/Permission.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Permission.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Fs;
+
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a file Permission.
+ *
+ * @property string $id Permission identifier
+ * @property string $file_id File identifier
+ * @property int $permissions Access rights
+ * @property string $user User identifier (email)
+ */
+class Permission extends Model
+{
+ use UuidStrKeyTrait;
+
+ public const READ = 1;
+ public const WRITE = 2;
+ public const DELETE = 4;
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'file_id',
+ 'permissions',
+ 'user',
+ ];
+
+ /** @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_permissions';
+
+
+ /**
+ * The file to which this permission belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function file()
+ {
+ return $this->belongsTo(File::class, 'file_id', 'id');
+ }
+}
diff --git a/src/app/Handlers/Beta/Files.php b/src/app/Handlers/Beta/Files.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Beta/Files.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace App\Handlers\Beta;
+
+class Files extends Base
+{
+ /**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\User::class;
+ }
+
+ /**
+ * 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/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,584 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Backends\Storage;
+use App\Fs\File;
+use App\Fs\Permission;
+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
+{
+ /** @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 = $this->inputFile($id, Permission::DELETE);
+
+ 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 = File::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->permissions()->orderBy('user')->get()->map(
+ fn($permission) => self::permissionToClient($permission)
+ );
+
+ $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'));
+
+ if (empty($errors['user']) && $file->permissions()->where('user', $user)->exists()) {
+ $errors['user'] = \trans('validation.file-perm-exists');
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ // Create the permission
+ $permission = $file->permissions()->create([
+ 'user' => $user,
+ 'permissions' => $acl,
+ ]);
+
+ $result = self::permissionToClient($permission);
+
+ 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);
+ }
+
+ $permission = $file->permissions()->find($id);
+
+ if (!$permission) {
+ return $this->errorResponse(404);
+ }
+
+ $permission->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);
+ }
+
+ $permission = $file->permissions()->find($id);
+
+ if (!$permission) {
+ 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']) && $user != $permission->user
+ && $file->permissions()->where('user', $user)->exists()
+ ) {
+ $errors['user'] = \trans('validation.file-perm-exists');
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $permission->user = $user;
+ $permission->permissions = $acl;
+ $permission->save();
+
+ $result = self::permissionToClient($permission);
+
+ 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->files()->where('mimetype', '<>', File::TYPE_INCOMPLETE);
+
+ if (strlen($search)) {
+ $result->whereLike('name', $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['mtime'] = $file->updated_at->format('Y-m-d H:i');
+
+ 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, Permission::READ);
+
+ if (is_int($file)) {
+ return $this->errorResponse($file);
+ }
+
+ $response = $this->objectToClient($file);
+
+ 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->files()->create([
+ 'mimetype' => File::TYPE_INCOMPLETE,
+ 'name' => $filename,
+ ]);
+
+ try {
+ $response = Storage::fileInput($request->getContent(true), $params, $file);
+
+ $response['status'] = 'success';
+
+ if (!empty($response['id'])) {
+ $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, Permission::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->name) {
+ $v = Validator::make($request->all(), ['name' => [new FileName($file->user)]]);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $file->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['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['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 \App\Fs\Permission $permission File permission record
+ *
+ * @return array Permission data
+ */
+ protected static function permissionToClient(Permission $permission): array
+ {
+ // 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 & Permission::DELETE) {
+ $perms = 'full';
+ } elseif ($permission->permissions & Permission::WRITE) {
+ $perms = 'read-write';
+ }
+
+ return [
+ 'id' => $permission->id,
+ 'user' => $permission->user,
+ 'permissions' => $perms ?? 'read-only',
+ 'link' => Utils::serviceUrl('file/share-' . $permission->id),
+ ];
+ }
+
+ /**
+ * Convert ACL label into internal permissions spec.
+ *
+ * @param string $acl Access rights label
+ *
+ * @return ?int Permissions
+ */
+ protected static function inputAcl($acl): ?int
+ {
+ // The ACL widget supports 'full', 'read-write', 'read-only',
+ if ($acl == 'full') {
+ return Permission::DELETE | Permission::WRITE | Permission::READ;
+ }
+
+ if ($acl == 'read-write') {
+ return Permission::WRITE | Permission::READ;
+ }
+
+ if ($acl == 'read-only') {
+ return Permission::READ;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the input file object, check permissions
+ *
+ * @param string $fileId File or file permission identifier
+ * @param ?int $permission Required access rights
+ *
+ * @return \App\Fs\File|int File object or error code
+ */
+ protected function inputFile($fileId, $permission)
+ {
+ $user = $this->guard()->user();
+
+ // Access via file permission identifier
+ if (str_starts_with($fileId, 'share-')) {
+ $fp = Permission::find(substr($fileId, 6));
+
+ if (!$fp) {
+ return 404;
+ }
+
+ if (!$permission || $fp->user != $user->email || !($fp->permissions & $permission)) {
+ return 403;
+ }
+
+ $fileId = $fp->file_id;
+ }
+
+ $file = File::find($fileId);
+
+ if (!$file) {
+ return 404;
+ }
+
+ // For direct file access check if the user is the file owner
+ if (empty($fp) && $user->id != $file->user_id) {
+ return 403;
+ }
+
+ return $file;
+ }
+}
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('beta-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,79 @@
+<?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->files()->where('name', $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
@@ -381,6 +381,16 @@
return false;
}
+ /**
+ * Storage files for this user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function files()
+ {
+ return $this->hasMany(Fs\File::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/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,
// 'daemonize' => false,
// 'log_level' => app()->environment('local') ? SWOOLE_LOG_INFO : SWOOLE_LOG_ERROR,
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,85 @@
+<?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_files',
+ function (Blueprint $table) {
+ $table->string('id', 36)->primary();
+ $table->bigInteger('user_id');
+ $table->string('name', 512);
+ $table->bigInteger('size')->unsigned()->default(0);
+ $table->string('mimetype');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('updated_at')->useCurrent();
+
+ $table->unique(['user_id', 'name']);
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ Schema::create(
+ 'fs_permissions',
+ function (Blueprint $table) {
+ $table->string('id', 36)->primary();
+ $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('fs_files')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ Schema::create(
+ 'fs_nodes',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('file_id', 36)->index();
+ $table->softDeletes();
+
+ $table->foreign('file_id')->references('id')->on('fs_files')
+ ->onUpdate('cascade')->onDelete('cascade');
+ }
+ );
+
+ if (!\App\Sku::where('title', 'beta-files')->first()) {
+ \App\Sku::create([
+ 'title' => 'beta-files',
+ 'name' => 'File storage',
+ 'description' => 'Access to file storage',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\Files',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('fs_permissions');
+ Schema::dropIfExists('fs_nodes');
+ Schema::dropIfExists('fs_files');
+ }
+};
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' => 'beta-files', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'beta-files',
+ 'name' => 'File storage',
+ 'description' => 'Access to file storage',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\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', 'beta-files')->first()) {
+ Sku::create([
+ 'title' => 'beta-files',
+ 'name' => 'File storage',
+ 'description' => 'Access to file storage',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\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
@@ -13,6 +13,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')
@@ -57,6 +59,18 @@
component: DomainListComponent,
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',
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,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",
@@ -43,6 +44,7 @@
'distlists' => "Distribution lists",
'chat' => "Video chat",
'domains' => "Domains",
+ 'files' => "Files",
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
@@ -109,6 +111,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",
@@ -137,6 +152,7 @@
'phone' => "Phone",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
+ 'size' => "Size",
'status' => "Status",
'surname' => "Surname",
'type' => "Type",
@@ -285,6 +301,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,141 @@
+<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="size">{{ $t('form.size') }}</th>
+ <th scope="col" class="mtime">{{ $t('file.mtime') }}</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="size">
+ {{ api.sizeText(file.size) }}
+ </td>
+ <td class="mtime">
+ {{ file.mtime }}
+ </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="4" :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
@@ -76,6 +76,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,741 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Backends\Storage;
+use App\Fs\File;
+use App\Fs\Permission;
+use App\User;
+use Illuminate\Support\Facades\Storage as LaravelStorage;
+use Tests\TestCase;
+
+class FilesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ File::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ File::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, File::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, File::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->size)
+ ->assertHeader('Content-Type', $file->mimetype . '; charset=UTF-8');
+
+ $this->assertSame('Teśt content', $response->streamedContent());
+
+ // Test acting as another user with read permission
+ $permission = $this->getTestFilePermission($file, $jack, Permission::READ);
+ $response = $this->actingAs($jack)->get("api/v4/files/share-{$permission->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->size)
+ ->assertHeader('Content-Type', $file->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->size)
+ ->assertHeader('Content-Type', $file->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 = $john->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(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->permissions()->orderBy('user')->first();
+
+ $this->assertSame(Permission::READ, $permission->permissions);
+ $this->assertSame($jack->email, $permission->user);
+ $this->assertSame($permission->id, $json['id']);
+ $this->assertSame($permission->user, $json['user']);
+ $this->assertSame('read-only', $json['permissions']);
+ $this->assertSame(\App\Utils::serviceUrl('file/share-' . $permission->id), $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->id}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions updated successfully.", $json['message']);
+
+ $permission->refresh();
+
+ $this->assertSame(Permission::WRITE | Permission::READ, $permission->permissions);
+ $this->assertSame($jack->email, $permission->user);
+
+ $this->assertSame($permission->id, $json['id']);
+ $this->assertSame($permission->user, $json['user']);
+ $this->assertSame('read-write', $json['permissions']);
+ $this->assertSame(\App\Utils::serviceUrl('file/share-' . $permission->id), $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->id}", $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->id, $json['list'][0]['id']);
+ $this->assertSame($permission->user, $json['list'][0]['user']);
+ $this->assertSame('read-write', $json['list'][0]['permissions']);
+ $this->assertSame(\App\Utils::serviceUrl('file/share-' . $permission->id), $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->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("File permissions deleted successfully.", $json['message']);
+
+ $this->assertCount(0, $file->permissions()->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 = $user->files()->create([
+ 'mimetype' => 'text/plain',
+ 'name' => 'test1.txt',
+ 'size' => 12345,
+ ]);
+ $file2 = $user->files()->create([
+ 'mimetype' => 'image/gif',
+ 'name' => 'test2.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']);
+
+ // 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($file2->name, $json['list'][0]['name']);
+ $this->assertSame($file2->id, $json['list'][0]['id']);
+ $this->assertSame($file2->mimetype, $json['list'][0]['mimetype']);
+ $this->assertSame($file2->size, $json['list'][0]['size']);
+
+ // 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->mimetype, $json['mimetype']);
+ $this->assertSame($file->size, $json['size']);
+ $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->size)
+ ->assertHeader('Content-Type', $file->mimetype . '; charset=UTF-8');
+
+ $this->assertSame('Teśt content', $response->streamedContent());
+
+ // Test acting as a user with file permissions
+ $permission = $this->getTestFilePermission($file, $jack, Permission::READ);
+ $response = $this->actingAs($jack)->get("api/v4/files/share-{$permission->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($file->id, $json['id']);
+ $this->assertSame($file->mimetype, $json['mimetype']);
+ $this->assertSame($file->size, $json['size']);
+ $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 = File::find($json['id']);
+
+ $this->assertSame($file->mimetype, $json['mimetype']);
+ $this->assertSame($json['size'], $file->size);
+ $this->assertSame('test.txt', $file->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 = File::find($json['id']);
+
+ $this->assertSame($json['mimetype'], $file->mimetype);
+ $this->assertSame($size, $file->size);
+ $this->assertSame('test2.txt', $file->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->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->mimetype);
+ $this->assertSame(strlen($body), $file->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
+ *
+ * @return \App\Fs\File
+ */
+ protected function getTestFile(User $user, string $name, $content = ''): File
+ {
+ $disk = LaravelStorage::disk('files');
+
+ $file = $user->files()->create([
+ 'mimetype' => File::TYPE_INCOMPLETE,
+ 'name' => $name,
+ ]);
+
+ $size = 0;
+ $mimetype = '';
+
+ foreach ((array) $content as $chunk) {
+ $node = $file->nodes()->create([]);
+ $path = Storage::nodeLocation($node, $file);
+
+ $disk->write($path, $chunk);
+
+ if (!$size) {
+ $mimetype = $disk->mimeType($path);
+ }
+
+ $size += strlen($chunk);
+ }
+
+ $file->size = $size;
+ $file->mimetype = $mimetype ?: 'application/octet-stream';
+ $file->save();
+
+ return $file;
+ }
+
+ /**
+ * Get contents of a test file.
+ *
+ * @param \App\Fs\File $file File record
+ *
+ * @return string
+ */
+ protected function getTestFileContent(File $file): string
+ {
+ $content = '';
+
+ $file->nodes()->orderBy('id')->get()->each(function ($node) use ($file, &$content) {
+ $disk = LaravelStorage::disk('files');
+ $path = Storage::nodeLocation($node, $file);
+
+ $content .= $disk->read($path);
+ });
+
+ return $content;
+ }
+
+ /**
+ * Create a test file permission.
+ *
+ * @param \App\Fs\File $file The file
+ * @param \App\User $user File owner
+ * @param int $permission File permission
+ *
+ * @return \App\Fs\Permission File permission object
+ */
+ protected function getTestFilePermission(File $file, User $user, int $permission): Permission
+ {
+ return $file->permissions()->create([
+ 'user' => $user->email,
+ 'permissions' => $permission,
+ ]);
+ }
+
+ /**
+ * Invoke a HTTP request with a custom raw body
+ *
+ * @param ?\App\User $user Authenticated user
+ * @param string $method Request method (POST, PUT)
+ * @param string $uri Request URL
+ * @param array $headers Request headers
+ * @param string $content Raw body content
+ *
+ * @return \Illuminate\Testing\TestResponse HTTP Response object
+ */
+ protected function sendRawBody(?User $user, string $method, string $uri, array $headers, string $content)
+ {
+ $headers['Content-Length'] = strlen($content);
+
+ $server = $this->transformHeadersToServerVars($headers);
+ $cookies = $this->prepareCookiesForRequest();
+
+ if ($user) {
+ return $this->actingAs($user)->call($method, $uri, [], $cookies, [], $server, $content);
+ } else {
+ // TODO: Make sure this does not use "acting user" set earlier
+ return $this->call($method, $uri, [], $cookies, [], $server, $content);
+ }
+ }
+}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 4:17 PM (1 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833884
Default Alt Text
D3463.1775405869.diff (106 KB)
Attached To
Mode
D3463: [WIP] Files API
Attached
Detach File
Event Timeline