Page MenuHomePhorge

D5721.1775293154.diff
No OneTemporary

Authored By
Unknown
Size
77 KB
Referenced Files
None
Subscribers
None

D5721.1775293154.diff

diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php
--- a/src/app/Backends/Storage.php
+++ b/src/app/Backends/Storage.php
@@ -68,6 +68,33 @@
$chunk->forceDelete();
}
+ /**
+ * Copy file content.
+ *
+ * @param Item $source Source file
+ * @param Item $target Target file
+ *
+ * @throws \Exception
+ */
+ public static function fileCopy(Item $source, Item $target): void
+ {
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
+
+ $source->chunks()->orderBy('sequence')->get()->each(static function ($chunk) use ($disk, $source, $target) {
+ $id = Utils::uuidStr();
+ $source_path = Storage::chunkLocation($chunk->chunk_id, $source);
+ $target_path = Storage::chunkLocation($id, $target);
+
+ $disk->copy($source_path, $target_path);
+
+ $target->chunks()->create([
+ 'chunk_id' => $id,
+ 'sequence' => $chunk->sequence,
+ 'size' => $chunk->size,
+ ]);
+ });
+ }
+
/**
* File download handler.
*
@@ -305,6 +332,9 @@
{
$disk = LaravelStorage::disk(\config('filesystems.default'));
+ // TODO: On S3-like storage this will fetch the file metadata,
+ // maybe we should just detect the mimetype ourselves when uploading it
+
$mimetype = $disk->mimeType($path);
// The mimetype may contain e.g. "; charset=UTF-8", remove this
diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php
--- a/src/app/Fs/Item.php
+++ b/src/app/Fs/Item.php
@@ -2,6 +2,7 @@
namespace App\Fs;
+use App\Backends\Storage;
use App\Traits\BelongsToUserTrait;
use App\Traits\UuidStrKeyTrait;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -50,6 +51,47 @@
return $this->hasMany(Chunk::class);
}
+ /**
+ * Copy the item to another location
+ *
+ * @param ?self $target Target folder
+ * @param ?string $name Optional name (for rename)
+ */
+ public function copy(?self $target, ?string $name = null): void
+ {
+ $is_file = !($this->type & self::TYPE_COLLECTION);
+
+ // Create the new item and copy its properties
+ $copy = new self();
+ $copy->type = $this->type;
+ $copy->user_id = $this->user_id;
+ $copy->updated_at = $this->updated_at;
+ $copy->save();
+
+ $props = $this->properties()->get()->mapWithKeys(function ($property) {
+ return [$property->key => new Property(['key' => $property->key, 'value' => $property->value])];
+ });
+
+ if (is_string($name) && strlen($name)) {
+ $props['name']->value = $name;
+ }
+
+ $copy->properties()->saveMany($props);
+
+ // Assign to the target folder
+ if ($target) {
+ $this->parents()->attach($target);
+ }
+
+ // FIXME: What can we do if copying content fails for any reason?
+
+ // Copy the contents
+ if ($is_file) {
+ Storage::fileCopy($this, $copy);
+ }
+ // TODO: Copying folders
+ }
+
/**
* Getter for the file path (without the filename) in the storage.
*/
@@ -112,6 +154,27 @@
return $props;
}
+ /**
+ * Move the item to another location
+ *
+ * @param ?self $target Target folder
+ * @param ?string $name Optional name (for rename)
+ */
+ public function move(?self $target, ?string $name = null): void
+ {
+ if ($target) {
+ // move to another folder
+ $this->parents()->sync([$target]);
+ } else {
+ // move to the root
+ $this->parents()->sync([]);
+ }
+
+ if (is_string($name) && strlen($name)) {
+ $this->setProperty('name', $name);
+ }
+ }
+
/**
* Remove a property
*
diff --git a/src/app/Fs/Relation.php b/src/app/Fs/Relation.php
--- a/src/app/Fs/Relation.php
+++ b/src/app/Fs/Relation.php
@@ -3,6 +3,7 @@
namespace App\Fs;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* The eloquent definition of a filesystem relation.
@@ -21,4 +22,24 @@
/** @var bool Indicates if the model should be timestamped. */
public $timestamps = false;
+
+ /**
+ * The item to which this relation belongs.
+ *
+ * @return BelongsTo<Item, $this>
+ */
+ public function item()
+ {
+ return $this->belongsTo(Item::class, 'item_id');
+ }
+
+ /**
+ * The item to which it relates.
+ *
+ * @return BelongsTo<Item, $this>
+ */
+ public function related()
+ {
+ return $this->belongsTo(Item::class, 'related_id');
+ }
}
diff --git a/src/app/Http/Controllers/DAVController.php b/src/app/Http/Controllers/DAVController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/DAVController.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\DAV;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Support\Facades\Route;
+use Sabre\DAV\Server;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class DAVController extends Controller
+{
+ /**
+ * Register WebDAV route(s)
+ */
+ public static function registerRoutes(): void
+ {
+ $root = trim(\config('services.dav.webdav_root'), '/');
+
+ Route::addRoute(
+ [
+ // Standard HTTP methods
+ 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT',
+ // WebDAV specific methods
+ 'MOVE', 'COPY', 'MKCOL', 'PROPFIND', 'PROPPATCH', 'REPORT',
+ // TODO: 'LOCK', 'UNLOCK',
+ ],
+ $root . '/{path?}',
+ [self::class, 'run']
+ )
+ ->where('path', '.*'); // This makes 'path' to match also sub-paths
+ }
+
+ /**
+ * Handle a WebDAV request
+ */
+ public function run(Request $request): Response|StreamedResponse
+ {
+ $sapi = new DAV\Sapi();
+ $auth_backend = new DAV\Auth();
+ // $locks_backend = new DAV\Locks();
+
+ // Initialize the Sabre DAV Server
+ $server = new Server(new DAV\Collection(''), $sapi);
+ $server->setBaseUri('/' . trim(\config('services.dav.webdav_root'), '/'));
+ $server->debugExceptions = \config('app.debug');
+ $server->enablePropfindDepthInfinity = false;
+ $server::$exposeVersion = false;
+ // FIXME: Streaming is supposed to improve memory use, but it changes
+ // how the response is handled in a way that e.g. for an unknown location you get
+ // 207 instead of 404. And our response handling is not working with this either.
+ // $server::$streamMultiStatus = true;
+
+ // Log important exceptions catched by Sabre
+ $server->on('exception', function ($e) {
+ if (!($e instanceof \Sabre\DAV\Exception) || $e->getHTTPCode() == 500) {
+ \Log::error($e);
+ }
+ });
+
+ // Register some plugins
+ $server->addPlugin(new \Sabre\DAV\Auth\Plugin($auth_backend));
+
+ // Unauthenticated access doesn't work for us since we require credentials to get access to the data in the first place.
+ $acl_plugin = new \Sabre\DAVACL\Plugin();
+ $acl_plugin->allowUnauthenticatedAccess = false;
+ $server->addPlugin($acl_plugin);
+
+ // The lock manager is responsible for making sure users don't overwrite each others changes.
+ // $server->addPlugin(new \Sabre\DAV\Locks\Plugin($locks_backend));
+
+ // Intercept some of the garbage files operating systems tend to generate when mounting a WebDAV share
+ // $server->addPlugin(new DAV\TempFiles());
+
+ // Finally, process the request
+ $server->start();
+
+ return $sapi->getResponse();
+ }
+}
diff --git a/src/app/Http/DAV/Auth.php b/src/app/Http/DAV/Auth.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/Auth.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Http\DAV;
+
+use App\User;
+use Sabre\DAV\Auth\Backend\AbstractBasic;
+
+/**
+ * Basic Authentication for WebDAV
+ */
+class Auth extends AbstractBasic
+{
+ // Make the current user available to all classes
+ public static $user;
+
+ /**
+ * Authentication Realm.
+ *
+ * The realm is often displayed by browser clients when showing the
+ * authentication dialog.
+ *
+ * @var string
+ */
+ protected $realm = 'Kolab/DAV';
+
+ /**
+ * This is the prefix that will be used to generate principal urls.
+ *
+ * @var string
+ */
+ protected $principalPrefix = 'dav/principals/';
+
+ /**
+ * Validates a username and password
+ *
+ * This method should return true or false depending on if login
+ * succeeded.
+ *
+ * @param string $username
+ * @param string $password
+ */
+ protected function validateUserPass($username, $password): bool
+ {
+ if (str_contains($username, '@')) {
+ $auth = User::findAndAuthenticate($username, $password);
+
+ if (!empty($auth['user'])) {
+ self::$user = $auth['user'];
+
+ // Cyrus DAV principal location
+ $this->principalPrefix = 'dav/principals/user/' . $username;
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/app/Http/DAV/Collection.php b/src/app/Http/DAV/Collection.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/Collection.php
@@ -0,0 +1,340 @@
+<?php
+
+namespace App\Http\DAV;
+
+use App\Backends\Storage;
+use App\Fs\Item;
+use Illuminate\Support\Facades\DB;
+use Sabre\DAV\Exception;
+use Sabre\DAV\ICollection;
+use Sabre\DAV\ICopyTarget;
+use Sabre\DAV\IMoveTarget;
+use Sabre\DAV\INode;
+use Sabre\DAV\IProperties;
+
+/**
+ * Sabre DAV Collection interface implemetation
+ */
+class Collection extends Node implements ICollection, ICopyTarget, IMoveTarget, IProperties
+{
+ /**
+ * Checks if a child-node exists.
+ *
+ * It is generally a good idea to try and override this. Usually it can be optimized.
+ *
+ * @param string $name
+ *
+ * @return bool
+ */
+ public function childExists($name)
+ {
+ $path = $this->nodePath($name);
+
+ \Log::debug('[DAV] CHILD-EXISTS: ' . $path);
+
+ try {
+ $this->fsItemForPath($path);
+ return true;
+ } catch (\Sabre\DAV\Exception\NotFound $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Copies a node into this collection.
+ *
+ * It is up to the implementors to:
+ * 1. Create the new resource.
+ * 2. Copy the data and any properties.
+ *
+ * If you return true from this function, the assumption
+ * is that the copy was successful.
+ * If you return false, sabre/dav will handle the copy itself.
+ *
+ * @param string $targetName New local file/collection name
+ * @param string $sourcePath Full path to source node
+ * @param INode $sourceNode Source node itself
+ *
+ * @return bool
+ */
+ public function copyInto($targetName, $sourcePath, INode $sourceNode)
+ {
+ $path = $this->nodePath($targetName);
+
+ \Log::debug("[DAV] COPY-INTO: {$sourcePath} > {$path}");
+
+ $item = $sourceNode->fsItem(); // @phpstan-ignore-line
+
+ $item->copy($this->data, $targetName);
+
+ $this->deleteCachedItem($path);
+
+ return true;
+ }
+
+ /**
+ * Creates a new file in the directory
+ *
+ * Data will either be supplied as a stream resource, or in certain cases
+ * as a string. Keep in mind that you may have to support either.
+ *
+ * After succesful creation of the file, you may choose to return the ETag
+ * of the new file here.
+ *
+ * The returned ETag must be surrounded by double-quotes (The quotes should
+ * be part of the actual string).
+ *
+ * If you cannot accurately determine the ETag, you should not return it.
+ * If you don't store the file exactly as-is (you're transforming it
+ * somehow) you should also not return an ETag.
+ *
+ * This means that if a subsequent GET to this new file does not exactly
+ * return the same contents of what was submitted here, you are strongly
+ * recommended to omit the ETag.
+ *
+ * @param string $name Name of the file
+ * @param resource|string $data Initial payload
+ *
+ * @return string|null
+ *
+ * @throws Exception
+ */
+ public function createFile($name, $data = null)
+ {
+ $path = $this->nodePath($name);
+
+ \Log::debug('[DAV] CREATE-FILE: ' . $path);
+
+ if (str_starts_with($name, '.')) {
+ throw new Exception\Forbidden('Hidden files are not accepted');
+ }
+
+ DB::beginTransaction();
+
+ $file = Auth::$user->fsItems()->create(['type' => Item::TYPE_FILE]);
+ $file->setProperty('name', $name);
+
+ if ($parent = $this->data) {
+ $parent->children()->attach($file->id);
+ }
+
+ // FIXME: For big files fileInput() can take a while, so use of transactions here might be a problem, or not?
+ // FIXME: fileInput() will detect input mimetype, should we rather trust the request Content-Type?
+ // TODO: fileInput() stores file in a single chunk, we need some other way.
+ Storage::fileInput($data, [], $file);
+
+ DB::commit();
+
+ // Refresh the file to get an up-to-date ETag
+ $file = new File($path, $this, $file);
+ $file->refresh();
+
+ return $file->getETag();
+ }
+
+ /**
+ * Creates a new subdirectory
+ *
+ * @param string $name
+ *
+ * @throws Exception
+ */
+ public function createDirectory($name)
+ {
+ \Log::debug('[DAV] CREATE-DIRECTORY: ' . $this->nodePath($name));
+
+ if (str_starts_with($name, '.')) {
+ throw new Exception\Forbidden('Hidden files are not accepted');
+ }
+
+ DB::beginTransaction();
+
+ $collection = Auth::$user->fsItems()->create(['type' => Item::TYPE_COLLECTION]);
+ $collection->setProperty('name', $name);
+
+ if ($parent = $this->data) {
+ $parent->children()->attach($collection->id);
+ }
+
+ DB::commit();
+ }
+
+ /**
+ * Deletes the current collection
+ *
+ * @throws \Exception
+ */
+ public function delete()
+ {
+ DB::beginTransaction();
+
+ parent::delete();
+
+ // Delete the files/folders inside
+ // TODO: The recursive aproach Optimize it for a case with a lot of folders
+ $this->data->children()->where('type', Item::TYPE_COLLECTION)
+ ->select('fs_items.*')
+ ->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
+ . ' and fs_properties.key = \'name\') as name')
+ ->get()
+ ->each(function ($folder) {
+ $path = $this->nodePath($folder->name); // @phpstan-ignore-line
+ $node = new self($path, $this, $folder);
+ $node->delete();
+ });
+
+ $this->data->children()->delete();
+
+ DB::commit();
+ }
+
+ /**
+ * Returns an array with all the child nodes.
+ *
+ * @return INode[]
+ *
+ * @throws \Exception
+ */
+ public function getChildren()
+ {
+ \Log::debug('[DAV] GET-CHILDREN: ' . $this->path);
+
+ $query = Auth::$user->fsItems()
+ ->select('fs_items.*')
+ ->whereNot('type', '&', Item::TYPE_INCOMPLETE);
+
+ foreach (['name', 'size', 'mimetype'] as $key) {
+ $query->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
+ . " and fs_properties.key = '{$key}') as {$key}");
+ }
+
+ if ($parent = $this->data) {
+ $query->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->where('fs_relations.item_id', $parent->id);
+ } else {
+ $query->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->whereNull('fs_relations.related_id');
+ }
+
+ return $query->orderBy('name')
+ ->get()
+ ->map(function ($item) {
+ $class = $item->type == Item::TYPE_COLLECTION ? Collection::class : File::class;
+ return new $class($this->nodePath($item), $this, $item);
+ })
+ ->all();
+ }
+
+ /**
+ * Returns a child object, by its name.
+ *
+ * Generally its wise to override this, as this can usually be optimized
+ *
+ * This method must throw Sabre\DAV\Exception\NotFound if the node does not
+ * exist.
+ *
+ * @param string $name
+ *
+ * @return INode
+ *
+ * @throws \Exception
+ */
+ public function getChild($name)
+ {
+ $path = $this->nodePath($name);
+
+ \Log::debug('[DAV] GET-CHILD: ' . $path);
+
+ if (str_starts_with($name, '.')) {
+ throw new Exception\NotFound("File not found: {$path}");
+ }
+
+ // Note: When e.g. creating a folder Sabre will call childExists() and then getChild(),
+ // so there's room for a performance improvement in such case
+
+ $item = $this->fsItemForPath($path);
+ $class = $item->type == Item::TYPE_COLLECTION ? self::class : File::class;
+
+ return new $class($path, $this, $item);
+ }
+
+ /**
+ * Returns a list of properties for this node.
+ *
+ * The properties list is a list of property names the client requested,
+ * encoded in clark-notation {xmlnamespace}tagname
+ *
+ * If the array is empty, it means 'all properties' were requested.
+ *
+ * Note that it's fine to liberally give properties back, instead of
+ * conforming to the list of requested properties.
+ * The Server class will filter out the extra.
+ *
+ * @param array $properties
+ *
+ * @return array
+ */
+ public function getProperties($properties)
+ {
+ $result = [];
+
+ if (!empty($this->data->created_at)) {
+ $result['{DAV:}creationdate'] = \Sabre\HTTP\toDate($this->data->created_at);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Moves a node into this collection.
+ *
+ * It is up to the implementors to:
+ * 1. Create the new resource.
+ * 2. Remove the old resource.
+ * 3. Transfer any properties or other data.
+ *
+ * Generally you should make very sure that your collection can easily move
+ * the node.
+ *
+ * If you don't, just return false, which will trigger sabre/dav to handle
+ * the move itself. If you return true from this function, the assumption
+ * is that the move was successful.
+ *
+ * @param string $targetName New local file/collection name
+ * @param string $sourcePath Full path to source node
+ * @param INode $sourceNode Source node itself
+ *
+ * @return bool
+ */
+ public function moveInto($targetName, $sourcePath, INode $sourceNode)
+ {
+ $path = $this->nodePath($targetName);
+
+ \Log::debug("[DAV] MOVE-INTO: {$sourcePath} > {$path}");
+
+ $item = $sourceNode->fsItem(); // @phpstan-ignore-line
+
+ $item->move($this->data, $targetName);
+
+ $this->deleteCachedItem($path);
+
+ return true;
+ }
+
+ /**
+ * Updates properties on this node.
+ *
+ * This method received a PropPatch object, which contains all the
+ * information about the update.
+ *
+ * To update specific properties, call the 'handle' method on this object.
+ * Read the PropPatch documentation for more information.
+ */
+ public function propPatch(\Sabre\DAV\PropPatch $propPatch)
+ {
+ \Log::debug('[DAV] PROP-PATCH: ' . $this->path);
+
+ // not supported
+ // FIXME: Should we throw an exception?
+ }
+}
diff --git a/src/app/Http/DAV/File.php b/src/app/Http/DAV/File.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/File.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace App\Http\DAV;
+
+use App\Backends\Storage;
+use Sabre\DAV\Exception;
+use Sabre\DAV\IFile;
+use Sabre\DAV\IProperties;
+
+/**
+ * Sabre DAV File interface implementation
+ */
+class File extends Node implements IFile, IProperties
+{
+ /**
+ * Returns the file content
+ *
+ * This method may either return a string or a readable stream resource
+ *
+ * @return mixed
+ *
+ * @throws \Exception
+ */
+ public function get()
+ {
+ \Log::debug('[DAV] GET: ' . $this->path);
+
+ if (!in_array('filestream', stream_get_wrappers())) {
+ stream_wrapper_register('filestream', FileStream::class);
+ }
+
+ $fp = fopen("filestream://{$this->data->id}", 'r');
+
+ return $fp;
+ }
+
+ /**
+ * Returns the mime-type for a file
+ *
+ * If null is returned, we'll assume application/octet-stream
+ *
+ * @return string|null
+ */
+ public function getContentType()
+ {
+ return $this->data?->mimetype ?? 'application/octet-stream'; // @phpstan-ignore-line
+ }
+
+ /**
+ * Returns the ETag for a file
+ *
+ * An ETag is a unique identifier representing the current version of the file. If the file changes, the ETag MUST change.
+ * The ETag is an arbitrary string, but MUST be surrounded by double-quotes.
+ *
+ * Return null if the ETag can not effectively be determined
+ *
+ * @return string|null
+ */
+ public function getETag()
+ {
+ return substr(md5($this->path . ':' . $this->data->size), 0, 16) // @phpstan-ignore-line
+ . '-' . $this->data->updated_at->getTimestamp();
+ }
+
+ /**
+ * Returns a list of properties for this node.
+ *
+ * The properties list is a list of property names the client requested,
+ * encoded in clark-notation {xmlnamespace}tagname
+ *
+ * If the array is empty, it means 'all properties' were requested.
+ *
+ * Note that it's fine to liberally give properties back, instead of
+ * conforming to the list of requested properties.
+ * The Server class will filter out the extra.
+ *
+ * @param array $properties
+ *
+ * @return array
+ */
+ public function getProperties($properties)
+ {
+ $result = [];
+
+ if (!empty($this->data->created_at)) {
+ $result['{DAV:}creationdate'] = \Sabre\HTTP\toDate($this->data->created_at);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns the size of the node, in bytes
+ *
+ * @return int
+ */
+ public function getSize()
+ {
+ return (int) $this->data?->size; // @phpstan-ignore-line
+ }
+
+ /**
+ * Updates properties on this node.
+ *
+ * This method received a PropPatch object, which contains all the
+ * information about the update.
+ *
+ * To update specific properties, call the 'handle' method on this object.
+ * Read the PropPatch documentation for more information.
+ */
+ public function propPatch(\Sabre\DAV\PropPatch $propPatch)
+ {
+ \Log::debug('[DAV] PROP-PATCH: ' . $this->path);
+
+ // not supported
+ // FIXME: Should we throw an exception?
+ }
+
+ /**
+ * Updates content of an existing file
+ *
+ * The data argument is a readable stream resource.
+ *
+ * After a succesful put operation, you may choose to return an ETag. The
+ * etag must always be surrounded by double-quotes. These quotes must
+ * appear in the actual string you're returning.
+ *
+ * Clients may use the ETag from a PUT request to later on make sure that
+ * when they update the file, the contents haven't changed in the mean
+ * time.
+ *
+ * If you don't plan to store the file byte-by-byte, and you return a
+ * different object on a subsequent GET you are strongly recommended to not
+ * return an ETag, and just return null.
+ *
+ * @param resource|string $data
+ *
+ * @return string|null
+ *
+ * @throws \Exception
+ */
+ public function put($data)
+ {
+ \Log::debug('[DAV] PUT: ' . $this->path);
+
+ // TODO: fileInput() method creates a non-chunked file, we need another way.
+
+ $result = Storage::fileInput($data, [], $this->data);
+
+ // Refresh the internal state for getETag()
+ $this->refresh();
+
+ return $this->getETag();
+ }
+
+ /**
+ * Refresh the internal state
+ */
+ public function refresh()
+ {
+ $this->data->refresh();
+ $this->data->properties()->whereIn('key', ['name', 'size', 'mimetype'])->each(function ($prop) {
+ $this->data->{$prop->key} = $prop->value;
+ });
+ }
+}
diff --git a/src/app/Http/DAV/FileStream.php b/src/app/Http/DAV/FileStream.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/FileStream.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace App\Http\DAV;
+
+use App\Backends\Storage;
+use App\Fs\Item;
+use Illuminate\Support\Facades\Storage as LaravelStorage;
+
+/*
+ * StreamWrapper implementation for streaming file contents
+ *
+ * https://www.php.net/manual/en/class.streamwrapper.php
+ */
+class FileStream
+{
+ private $chunks;
+ private $disk;
+ private Item $item;
+ private int $position = 0;
+ private int $size = 0;
+
+ /**
+ * Stream opening handler.
+ */
+ public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
+ {
+ // We expect $path to be filestream://<item-id>
+ $this->item = Item::find(explode('//', $path)[1]);
+ $this->chunks = $this->item->chunks()->orderBy('sequence')->get();
+ $this->disk = LaravelStorage::disk(\config('filesystems.default'));
+
+ foreach ($this->chunks as $chunk) {
+ $this->size += $chunk->size;
+ }
+
+ return true;
+ }
+
+ /**
+ * Stream reading handler.
+ */
+ public function stream_read(int $count)
+ {
+ if ($this->position >= $this->size) {
+ return false;
+ }
+
+ if ($count <= 0) {
+ return '';
+ }
+
+ $output = '';
+ $pos = 0;
+
+ foreach ($this->chunks as $chunk) {
+ if ($this->position <= $pos + $chunk->size) {
+ $offset = $this->position - $pos;
+ $length = min($count, $chunk->size - $offset);
+
+ $path = Storage::chunkLocation($chunk->chunk_id, $this->item);
+ $stream = $this->disk->readStream($path);
+ $body = stream_get_contents($stream, $length, $offset);
+
+ if ($body === false) {
+ // FIXME: Can we throw exceptions from a stream wrapper?
+ throw new \Exception("Failed to read Streamed file '{$this->item->id}'");
+ }
+
+ $output .= $body;
+ $this->position += $length;
+
+ if ($length >= $count) {
+ break;
+ }
+ }
+
+ $pos += $chunk->size;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Stream EOF check handler. See feof().
+ */
+ public function stream_eof(): bool
+ {
+ return $this->position >= $this->size;
+ }
+
+ /**
+ * Stream seeking handler. See fseek().
+ */
+ public function stream_seek(int $offset, int $whence): bool
+ {
+ switch ($whence) {
+ case \SEEK_SET:
+ $this->position = $offset;
+ break;
+ case \SEEK_CUR:
+ $this->position += $offset;
+ break;
+ case \SEEK_END:
+ $this->position = $this->size + $offset;
+ break;
+ }
+
+ return true;
+ }
+
+ /**
+ * Stream tell handler. See ftell().
+ */
+ public function stream_tell(): int
+ {
+ return $this->position;
+ }
+
+ /**
+ * Stream writing handler.
+ */
+ public function stream_write(string $data): int
+ {
+ throw new \Exception("File stream is readonly");
+ }
+}
diff --git a/src/app/Http/DAV/Node.php b/src/app/Http/DAV/Node.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/Node.php
@@ -0,0 +1,219 @@
+<?php
+
+namespace App\Http\DAV;
+
+use App\Fs\Item;
+use Illuminate\Support\Facades\Context;
+use Sabre\DAV\Exception;
+use Sabre\DAV\INode;
+
+/**
+ * Sabre DAV Node interface implementation
+ */
+class Node implements INode
+{
+ /** @var string The path to the current node */
+ protected $path;
+
+ /** @var ?Item Internal node data (e.g. file/folder properties) */
+ protected $data;
+
+ /** @var ?Node Parent node */
+ protected $parent;
+
+ /**
+ * Sets up the node, expects a full path name
+ *
+ * @param string $path Node name with path
+ * @param ?Node $parent Parent node
+ * @param ?Item $data Node data
+ */
+ public function __construct($path, $parent = null, $data = null)
+ {
+ $root = trim(\config('services.dav.webdav_root'), '/');
+
+ if ($path === $root) {
+ $path = '';
+ } elseif (str_starts_with($path, "{$root}/")) {
+ $path = substr($path, strlen("{$root}/"));
+ }
+
+ $this->data = $data;
+ $this->path = $path;
+ $this->parent = $parent;
+ }
+
+ /**
+ * Deletes the current node
+ *
+ * @throws \Exception
+ */
+ public function delete()
+ {
+ \Log::debug('[DAV] DELETE: ' . $this->path);
+
+ // Here we're just marking the nodes as deleted, they will be removed from the
+ // storage later with the fs:expunge command
+ // FIXME: This will also bump the updated_at timestamp, should we prevent that?
+ // Note: Deleting a collection is handled by Collection::delete()
+
+ $this->data->delete();
+ }
+
+ /**
+ * Get the filesystem item for the node
+ */
+ public function fsItem(): ?Item
+ {
+ return $this->data;
+ }
+
+ /**
+ * Returns the last modification time
+ *
+ * @return int|null
+ */
+ public function getLastModified()
+ {
+ // FIXME: What about last-modified for folders? Should we return null?
+ return $this->data?->updated_at?->getTimestamp();
+ }
+
+ /**
+ * Returns the name of the node
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ if ($this->path === '') {
+ return '';
+ }
+
+ return array_last(explode('/', $this->path));
+ }
+
+ /**
+ * Renames the node
+ *
+ * @param string $name The new name
+ *
+ * @throws \Exception
+ */
+ public function setName($name)
+ {
+ \Log::debug('[DAV] SET-NAME: ' . $this->path);
+
+ $this->data->setProperty('name', $name);
+ }
+
+ /**
+ * Return DAV path for a filesystem item (file or folder)
+ *
+ * @param string|Item $item
+ */
+ protected function nodePath($item): string
+ {
+ return (strlen($this->path) ? $this->path . '/' : '')
+ . (is_string($item) ? $item : $item->name); // @phpstan-ignore-line
+ }
+
+ /**
+ * Find the filesystem item record for the current path
+ *
+ * @return ?Item Found Item or Null for empty path (root)
+ *
+ * @throws Exception\NotFound For not found non-root folder/file
+ */
+ protected function fsItemForPath(string $path): ?Item
+ {
+ if (!strlen($path)) {
+ return null;
+ }
+
+ if (($item = $this->getCachedItem($path)) !== null) {
+ if ($item === false) {
+ throw new Exception\NotFound("Unknown location: {$path}");
+ }
+ return $item;
+ }
+
+ $path = explode('/', $path);
+ $count = count($path);
+ $parent = $item = null;
+
+ for ($i = 0; $i < $count; $i++) {
+ $item_path = implode('/', array_slice($path, 0, $i + 1));
+ $item_name = $path[$i];
+
+ $item = $this->getCachedItem($item_path);
+
+ if ($item === null) {
+ $query = Auth::$user->fsItems()->select('fs_items.*', 'fs_properties.value as name')
+ ->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
+ ->where('key', 'name')
+ ->where('value', $item_name); // TODO: Make sure it's a case-sensitive match?
+
+ if ($parent) {
+ $query->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->where('fs_relations.item_id', $parent->id);
+ }
+
+ $item = $query->first();
+
+ // Get file properties
+ if ($item && $item->type == Item::TYPE_FILE) {
+ $item->properties()->whereIn('key', ['size', 'mimetype'])->each(function ($prop) use ($item) {
+ $item->{$prop->key} = $prop->value;
+ });
+ }
+ }
+
+ // Cache the last item and its parent
+ if ($item !== false && $i >= $count - 2) {
+ $this->setCachedItem($item_path, $item ?? false);
+ }
+
+ if (!$item) {
+ throw new Exception\NotFound("Unknown location: {$item_path}");
+ }
+
+ $parent = $item;
+ }
+
+ return $item;
+ }
+
+ /**
+ * Delete cached filesystem item
+ */
+ protected function deleteCachedItem(string $path): void
+ {
+ Context::forgetHidden('fs:' . $path);
+ }
+
+ /**
+ * Get cached filesystem item
+ *
+ * @return Item|false|null
+ */
+ protected function getCachedItem(string $path)
+ {
+ return Context::getHidden('fs:' . $path);
+ }
+
+ /**
+ * Store cached filesystem item into a request context
+ *
+ * @param string $path Item path
+ * @param Item|false|null $item Item or false if we know it does not exist
+ */
+ protected function setCachedItem(string $path, $item): void
+ {
+ // A very common sequence of Sabre DAV Server actions is childExists(), getChild(), then action on it.
+ // So, by caching the first lookup we can save quite a lot of time.
+ // Note: It will often call getChild() even if childExists() returned false,
+ // that's why we store all lookup results including `false`.
+ Context::addHidden('fs:' . $path, $item);
+ }
+}
diff --git a/src/app/Http/DAV/Sapi.php b/src/app/Http/DAV/Sapi.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/Sapi.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Http\DAV;
+
+use Sabre\HTTP\Request;
+use Sabre\HTTP\ResponseInterface;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+/**
+ * Sabre SAPI implementation that uses Laravel request/response for input/output.
+ */
+class Sapi extends \Sabre\HTTP\Sapi
+{
+ private static $response;
+
+ /**
+ * This static method will create a new Request object, based on the current PHP request.
+ */
+ public static function getRequest(): Request
+ {
+ $request = \request();
+
+ $headers = [];
+ foreach ($request->headers as $key => $val) {
+ if (is_array($val)) {
+ $headers[$key] = implode("\n", $val);
+ }
+ }
+
+ // TODO: For now we create the Sabre's Request object. For better performance
+ // and memory usage we should replece it completely with a "direct" access to Laravel's Request.
+
+ $r = new Request($request->method(), $request->path(), $headers);
+ $r->setHttpVersion('1.1');
+ // $r->setRawServerData($_SERVER);
+ $r->setAbsoluteUrl($request->url());
+ $r->setBody($request->getContent(true));
+ $r->setPostData($request->all());
+
+ return $r;
+ }
+
+ /**
+ * Laravel Response object getter. To be called after Sapi::sendResponse()
+ *
+ * This method is for Kolab only, is not part of the Sabre SAPI interface.
+ */
+ public function getResponse()
+ {
+ return self::$response;
+ }
+
+ /**
+ * Override Sabre's Sapi HTTP response sending. Create Laravel's Response
+ * to be returned from getResponse()
+ */
+ public static function sendResponse(ResponseInterface $response): void
+ {
+ $callback = function () use ($response): void {
+ $body = $response->getBody();
+
+ if ($body === null || is_string($body)) {
+ echo $body;
+ } elseif (is_callable($body)) {
+ // FIXME: A callable seems to be used only with streamMultiStatus=true
+ $body();
+ } elseif (is_resource($body)) {
+ $content_length = $response->getHeader('Content-Length');
+ $length = is_numeric($content_length) ? (int) $content_length : null;
+
+ if ($length === null) {
+ while (!feof($body)) {
+ echo fread($body, 10 * 1024 * 1024);
+ }
+ } else {
+ while ($length > 0) {
+ $size = min($length, 10 * 1024 * 1024);
+ echo fread($body, $length);
+ $length -= $size;
+ }
+ }
+ fclose($body);
+ }
+ };
+
+ // FIXME: Should we use non-streamed responses for small bodies?
+
+ self::$response = new StreamedResponse($callback, $response->getStatus(), $response->getHeaders());
+ }
+}
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
@@ -21,6 +21,7 @@
];
// Exclude horizon routes, per https://github.com/laravel/horizon/issues/576
+ // FIXME: Also exclude WebDAV?
if ($request->is('horizon*')) {
$headers = [];
}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -35,6 +35,7 @@
"pear/crypt_gpg": "^1.6.6",
"pear/mail_mime": "~1.10.11",
"predis/predis": "^2.0",
+ "sabre/dav": "dev-master",
"sabre/vobject": "^4.5",
"spatie/laravel-translatable": "^6.5",
"spomky-labs/otphp": "~10.0.0",
diff --git a/src/config/app.php b/src/config/app.php
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -249,6 +249,7 @@
'with_admin' => (bool) env('APP_WITH_ADMIN', false),
'with_files' => (bool) env('APP_WITH_FILES', false),
+ 'with_webdav' => (bool) env('APP_WITH_WEBDAV', env('APP_WITH_FILES', false)),
'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
'with_services' => (bool) env('APP_WITH_SERVICES', false),
'with_signup' => (bool) env('APP_WITH_SIGNUP', true),
diff --git a/src/config/services.php b/src/config/services.php
--- a/src/config/services.php
+++ b/src/config/services.php
@@ -62,6 +62,10 @@
'uri' => env('DAV_URI', 'https://proxy/'),
'default_folders' => Helper::defaultDavFolders(),
'verify' => (bool) env('DAV_VERIFY', true),
+
+ // FIXME: /dav/files is used in Kolab v3, Cyrus DAV uses /dav/drive/user/{email}.
+ // Should we keep compat. with any of them or go for something different?
+ 'webdav_root' => env('DAV_WEBDAV_ROOT', 'dav/files'),
],
'imap' => [
diff --git a/src/routes/web.php b/src/routes/web.php
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -66,4 +66,8 @@
}
);
+if (\config('app.with_webdav')) {
+ Controllers\DAVController::registerRoutes();
+}
+
Controllers\DiscoveryController::registerRoutes();
diff --git a/src/tests/Feature/Controller/DAVTest.php b/src/tests/Feature/Controller/DAVTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/DAVTest.php
@@ -0,0 +1,605 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Fs\Item;
+use App\User;
+use Illuminate\Support\Facades\Context;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+use Tests\TestCaseFs;
+
+class DAVTest extends TestCaseFs
+{
+ /**
+ * Test basic COPY requests
+ */
+ public function testCopy(): void
+ {
+ $host = trim(\config('app.url'), '/');
+ $john = $this->getTestUser('john@kolab.org');
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('COPY', 'dav/files/test1.txt', '', null, ['Destination' => "{$host}/dav/files/moved1.txt"]);
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test copying a non-existing file
+ $response = $this->davRequest('COPY', 'dav/files/unknown', '', $john, ['Destination' => "{$host}/dav/files/moved.txt"]);
+ $response->assertNoContent(404);
+
+ // Test a file copy into non-existing location
+ $response = $this->davRequest('COPY', 'dav/files/test1.txt', '', $john, ['Destination' => "{$host}/dav/files/unknown/test1.txt"]);
+ $response->assertNoContent(409);
+
+ // Test a file copy "in place" with rename
+ $response = $this->davRequest('COPY', 'dav/files/test1.txt', '', $john, ['Destination' => "{$host}/dav/files/moved1.txt"]);
+ $response->assertNoContent(201);
+
+ $all_items = array_merge(array_column($files, 'id'), array_column($folders, 'id'));
+ $file = $john->fsItems()->whereNotIn('id', $all_items)->first();
+ $this->assertSame($files[0]->type, $file->type);
+ $this->assertSame($files[0]->updated_at->getTimestamp(), $file->updated_at->getTimestamp());
+ $this->assertCount(0, $file->parents()->get());
+ $this->assertSame('moved1.txt', $file->getProperty('name'));
+ $this->assertSame('text/plain', $file->getProperty('mimetype'));
+ $this->assertSame('13', $file->getProperty('size'));
+ $all_items[] = $file->id;
+
+ // Test an empty folder copy "in place" with rename
+ $empty_folder = $this->getTestCollection($john, 'folder-empty');
+ $all_items[] = $empty_folder->id;
+ $response = $this->davRequest('COPY', 'dav/files/folder-empty', '', $john, ['Destination' => "{$host}/dav/files/folder-copy"]);
+ $response->assertNoContent(201);
+
+ $folder = $john->fsItems()->whereNotIn('id', $all_items)->first();
+ $this->assertSame($empty_folder->type, $folder->type);
+ $this->assertSame($empty_folder->updated_at->getTimestamp(), $folder->updated_at->getTimestamp());
+ $this->assertCount(0, $folder->parents()->get());
+ $this->assertSame('folder-copy', $folder->getProperty('name'));
+
+ // TODO: Copying non-empty folders
+ // TODO: Copy into an existing location (with and without "Overwrite:T" header)
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test basic DELETE requests
+ */
+ public function testDelete(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ [$folders, $files] = $this->initTestStorage($john);
+ $folders[] = $this->getTestCollection($john, 'folder3');
+ $files[] = $this->getTestFile($john, 'test5.txt', 'Test');
+ $folders[0]->children()->attach($folders[2]);
+ $folders[2]->children()->attach($files[4]);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('DELETE', 'dav/files/test1.txt');
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test non-existing location
+ $response = $this->davRequest('DELETE', 'dav/files/unknown', '', $john);
+ $response->assertNoContent(404);
+
+ // Test deleting a file in the root
+ $response = $this->davRequest('DELETE', 'dav/files/test1.txt', '', $john);
+ $response->assertNoContent(204);
+
+ $this->assertTrue($files[0]->fresh()->trashed());
+
+ // Test deleting a file in a folder
+ $response = $this->davRequest('DELETE', 'dav/files/folder1/test3.txt', '', $john);
+ $response->assertNoContent(204);
+
+ $this->assertTrue($files[2]->fresh()->trashed());
+
+ // Test deleting a folder
+ $response = $this->davRequest('DELETE', 'dav/files/folder1', '', $john);
+ $response->assertNoContent(204);
+
+ $this->assertTrue($folders[0]->fresh()->trashed());
+ $this->assertTrue($folders[1]->fresh()->trashed());
+ $this->assertTrue($folders[2]->fresh()->trashed());
+ $this->assertTrue($files[3]->fresh()->trashed());
+ $this->assertTrue($files[4]->fresh()->trashed());
+ $this->assertFalse($files[1]->fresh()->trashed());
+
+ // Test deleting the root
+ $response = $this->davRequest('DELETE', 'dav/files', '', $john);
+ $response->assertNoContent(403);
+
+ $this->assertFalse($files[1]->fresh()->trashed());
+ }
+
+ /**
+ * Test basic GET requests
+ */
+ public function testGet(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $content = [
+ 'Test content1',
+ 'Test content2',
+ 'Test content3',
+ ];
+ $content_length = strlen(implode('', $content));
+ $file = $this->getTestFile($john, 'test1.txt', $content, ['mimetype' => 'text/plain']);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('GET', 'dav/files/test1.txt');
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test non-existing location
+ $response = $this->davRequest('GET', 'dav/files/unknown', '', $john);
+ $response->assertNoContent(404);
+
+ // Test with a valid Authorization header
+ $response = $this->davRequest('GET', 'dav/files/test1.txt', '', $john);
+ $response->assertStatus(200);
+ $response->assertStreamedContent(implode('', $content));
+
+ // Test Range header
+ $range = 'bytes=0-' . $content_length - 1;
+ $response = $this->davRequest('GET', 'dav/files/test1.txt', '', $john, ['Range' => $range]);
+ $response->assertStatus(206);
+ $response->assertStreamedContent(implode('', $content));
+ $response->assertHeader('Content-Length', $content_length);
+ $response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
+
+ $range = 'bytes=5-' . strlen($content[0]) - 1;
+ $response = $this->davRequest('GET', 'dav/files/test1.txt', '', $john, ['Range' => $range]);
+ $response->assertStatus(206);
+ $response->assertStreamedContent('content1');
+ $response->assertHeader('Content-Length', strlen('content1'));
+ $response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
+
+ $range = sprintf('bytes=%d-%d', strlen($content[0]), strlen($content[0]) + 3);
+ $response = $this->davRequest('GET', 'dav/files/test1.txt', '', $john, ['Range' => $range]);
+ $response->assertStatus(206);
+ $response->assertStreamedContent('Test');
+ $response->assertHeader('Content-Length', strlen('Test'));
+ $response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
+
+ $range = 'bytes=12-26';
+ $response = $this->davRequest('GET', 'dav/files/test1.txt', '', $john, ['Range' => $range]);
+ $response->assertStatus(206);
+ $response->assertStreamedContent('1Test content2T');
+ $response->assertHeader('Content-Length', strlen('1Test content2T'));
+ $response->assertHeader('Content-Range', str_replace('=', ' ', $range) . '/' . $content_length);
+
+ // TODO: Test big files >10MB
+ // TODO: Test GET on a collection
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test basic HEAD requests
+ */
+ public function testHead(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('HEAD', 'dav/files/test1.txt');
+ $response->assertNoContent(401);
+
+ // Test non-existing location
+ $response = $this->davRequest('HEAD', 'dav/files/unknown', '', $john);
+ $response->assertNoContent(404);
+
+ // Test with a valid Authorization header
+ $response = $this->davRequest('HEAD', 'dav/files/test1.txt', '', $john);
+ $response->assertNoContent(200);
+
+ // TODO: Test HEAD on a collection
+ }
+
+ /**
+ * Test basic MKCOL requests
+ */
+ public function testMkcol(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test with no Authorization header
+ $response = $this->davRequest('MKCOL', 'dav/files/folder1');
+ $response->assertStatus(401);
+
+ // Test creating a collection in the root
+ $response = $this->davRequest('MKCOL', 'dav/files/folder1', '', $john);
+ $response->assertNoContent(201);
+
+ $this->assertCount(1, $items = $john->fsItems()->get());
+ $this->assertSame('folder1', $items[0]->getProperty('name'));
+
+ // Test collection already exists case
+ $response = $this->davRequest('MKCOL', 'dav/files/folder1', '', $john);
+ $response->assertStatus(405);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test creating a collection in another folder
+ $response = $this->davRequest('MKCOL', 'dav/files/folder1/folder2', '', $john);
+ $response->assertNoContent(201);
+
+ $this->assertCount(1, $children = $items[0]->children()->get());
+ $this->assertSame($john->id, $children[0]->user_id);
+ $this->assertSame('folder2', $children[0]->getProperty('name'));
+ }
+
+ /**
+ * Test basic MOVE requests
+ */
+ public function testMove(): void
+ {
+ $host = trim(\config('app.url'), '/');
+ $john = $this->getTestUser('john@kolab.org');
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('MOVE', 'dav/files/test1.txt', '', null, ['Destination' => "{$host}/dav/files/moved1.txt"]);
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test moving a non-existing file
+ $response = $this->davRequest('MOVE', 'dav/files/unknown', '', $john, ['Destination' => "{$host}/dav/files/moved.txt"]);
+ $response->assertNoContent(404);
+
+ // Test a file rename
+ $response = $this->davRequest('MOVE', 'dav/files/test1.txt', '', $john, ['Destination' => "{$host}/dav/files/moved1.txt"]);
+ $response->assertNoContent(201);
+
+ $this->assertCount(0, $files[0]->parents()->get());
+ $this->assertSame('moved1.txt', $files[0]->getProperty('name'));
+
+ // Test a folder rename
+ $response = $this->davRequest('MOVE', 'dav/files/folder1', '', $john, ['Destination' => "{$host}/dav/files/folder10"]);
+ $response->assertNoContent(201);
+
+ $this->assertCount(0, $folders[0]->parents()->get());
+ $this->assertSame('folder10', $folders[0]->getProperty('name'));
+
+ // Test moving a sub-folder into the root
+ $response = $this->davRequest('MOVE', 'dav/files/folder10/folder2', '', $john, ['Destination' => "{$host}/dav/files/folder20"]);
+ $response->assertNoContent(201);
+
+ $this->assertCount(0, $folders[1]->parents()->get());
+ $this->assertSame('folder20', $folders[1]->getProperty('name'));
+
+ // Test moving a file into the root
+ $response = $this->davRequest('MOVE', 'dav/files/folder10/test3.txt', '', $john, ['Destination' => "{$host}/dav/files/test30.txt"]);
+ $response->assertNoContent(201);
+
+ $this->assertCount(0, $files[2]->parents()->get());
+ $this->assertSame('test30.txt', $files[2]->getProperty('name'));
+ $this->assertSame('text/plain', $files[2]->getProperty('mimetype'));
+
+ // Test moving a folder from root into into another folder (no rename)
+ $response = $this->davRequest('MOVE', 'dav/files/folder20', '', $john, ['Destination' => "{$host}/dav/files/folder10/folder20"]);
+ $response->assertNoContent(201);
+
+ $this->assertSame([$folders[0]->id], $folders[1]->parents()->get()->pluck('id')->all());
+ $this->assertSame('folder20', $folders[1]->getProperty('name'));
+
+ // Test moving a file from the root into another folder
+ $response = $this->davRequest('MOVE', 'dav/files/test2.txt', '', $john, ['Destination' => "{$host}/dav/files/folder10/test20.txt"]);
+ $response->assertNoContent(201);
+
+ $this->assertSame([$folders[0]->id], $files[1]->parents()->get()->pluck('id')->all());
+ $this->assertSame('test20.txt', $files[1]->getProperty('name'));
+ $this->assertSame('text/html', $files[1]->getProperty('mimetype'));
+ }
+
+ /**
+ * Test basic OPTIONS requests
+ */
+ public function testOptions(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test with no Authorization header
+ // FIXME: Looking at https://datatracker.ietf.org/doc/html/rfc3744#section-7.2.1
+ // it does not seem that this should require an authenticated user, but allowing OPTIONS
+ // on any location to anyone might not be the best idea either. Should we at least allow
+ // unauthenticated OPTIONS on the root?
+ $response = $this->davRequest('OPTIONS', 'dav/files');
+ $response->assertStatus(401);
+
+ // Test with valid Authorization header
+ $response = $this->davRequest('OPTIONS', 'dav/files', '', $john);
+ $response->assertNoContent(200);
+
+ // FIXME: Verify the supported features set
+ $this->assertSame('1, 3, extended-mkcol, access-control, calendarserver-principal-property-search', $response->headers->get('DAV'));
+ }
+
+ /**
+ * Test basic PROPFIND requests on the root location
+ */
+ public function testPropfindOnTheRootFolder(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PROPFIND', 'dav/files', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>');
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test with valid Authorization header, non-existing location
+ $response = $this->davRequest('PROPFIND', 'dav/files/unknown', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
+ $response->assertStatus(404);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with valid Authorization header
+ $response = $this->davRequest('PROPFIND', 'dav/files', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
+
+ // the root folder
+ $this->assertSame('/dav/files/', $responses[0]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertCount(1, $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes);
+ $this->assertSame('collection', $responses[0]->getElementsByTagName('resourcetype')->item(0)->firstChild->localName);
+ $this->assertCount(1, $responses[0]->getElementsByTagName('prop')->item(0)->childNodes);
+ $this->assertStringContainsString('200 OK', $responses[0]->getElementsByTagName('status')->item(0)->textContent);
+
+ // the subfolder folder
+ $this->assertSame('/dav/files/folder1/', $responses[1]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertCount(1, $responses[1]->getElementsByTagName('resourcetype')->item(0)->childNodes);
+ $this->assertSame('collection', $responses[1]->getElementsByTagName('resourcetype')->item(0)->firstChild->localName);
+ $prop = $responses[1]->getElementsByTagName('prop')->item(0);
+ $this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('getlastmodified')->item(0)->textContent);
+ $this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('creationdate')->item(0)->textContent);
+ $this->assertStringContainsString('200 OK', $responses[1]->getElementsByTagName('status')->item(0)->textContent);
+ $this->assertCount(0, $prop->getElementsByTagName('contentlength'));
+ $this->assertCount(0, $prop->getElementsByTagName('getcontenttype'));
+ $this->assertCount(0, $prop->getElementsByTagName('getetag'));
+
+ // the files
+ $this->assertSame('/dav/files/test1.txt', $responses[2]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertCount(0, $responses[2]->getElementsByTagName('resourcetype')->item(0)->childNodes);
+ $prop = $responses[2]->getElementsByTagName('prop')->item(0);
+ $this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('getlastmodified')->item(0)->textContent);
+ $this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('creationdate')->item(0)->textContent);
+ $this->assertSame('13', $prop->getElementsByTagName('getcontentlength')->item(0)->textContent);
+ $this->assertSame('text/plain', $prop->getElementsByTagName('getcontenttype')->item(0)->textContent);
+ $this->assertCount(1, $prop->getElementsByTagName('getetag'));
+ $this->assertStringContainsString('200 OK', $responses[2]->getElementsByTagName('status')->item(0)->textContent);
+
+ $this->assertSame('/dav/files/test2.txt', $responses[3]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertCount(0, $responses[3]->getElementsByTagName('resourcetype')->item(0)->childNodes);
+ $prop = $responses[3]->getElementsByTagName('prop')->item(0);
+ $this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('getlastmodified')->item(0)->textContent);
+ $this->assertStringContainsString(now()->format('D'), $prop->getElementsByTagName('creationdate')->item(0)->textContent);
+ $this->assertSame('22', $prop->getElementsByTagName('getcontentlength')->item(0)->textContent);
+ $this->assertSame('text/html', $prop->getElementsByTagName('getcontenttype')->item(0)->textContent);
+ $this->assertCount(1, $prop->getElementsByTagName('getetag'));
+ $this->assertStringContainsString('200 OK', $responses[3]->getElementsByTagName('status')->item(0)->textContent);
+
+ // Test Depth:0
+ $response = $this->davRequest('PROPFIND', 'dav/files', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john, ['Depth' => '0']);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(1, $responses = $doc->documentElement->getElementsByTagName('response'));
+ $this->assertSame('/dav/files/', $doc->getElementsByTagName('href')->item(0)->textContent);
+
+ // Test that Depth:infinity is not supported
+ // FIXME: Seems Sabre fallsbacks to Depth:1 and does not respond with an error
+ $response = $this->davRequest('PROPFIND', 'dav/files', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john, ['Depth' => 'infinity']);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
+ }
+
+ /**
+ * Test basic PROPFIND requests on non-root folder
+ */
+ public function testPropfindOnCustomFolder(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PROPFIND', 'dav/files/folder1', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>');
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test with valid Authorization header
+ $response = $this->davRequest('PROPFIND', 'dav/files/folder1', '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
+
+ $this->assertSame('/dav/files/folder1/', $responses[0]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame('/dav/files/folder1/folder2/', $responses[1]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame('/dav/files/folder1/test3.txt', $responses[2]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame('/dav/files/folder1/test4.txt', $responses[3]->getElementsByTagName('href')->item(0)->textContent);
+ }
+
+ /**
+ * Test basic PUT requests
+ */
+ public function testPut(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PUT', 'dav/files/test.txt', 'Test', null, ['Content-Type' => 'text/plain']);
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test creating a file in the root folder (non-empty file)
+ $response = $this->davRequest('PUT', 'dav/files/test.txt', 'Test', $john, ['Content-Type' => 'text/plain']);
+ $response->assertNoContent(201);
+ $response->assertHeader('ETag');
+
+ $this->assertCount(1, $files = $john->fsItems()->where('type', Item::TYPE_FILE)->get());
+ $this->assertSame('test.txt', $files[0]->getProperty('name'));
+ $this->assertSame('4', $files[0]->getProperty('size'));
+ // TODO: $this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
+ $this->assertSame('Test', $this->getTestFileContent($files[0]));
+
+ // Test updating a file in the root folder (empty file)
+ $response = $this->davRequest('PUT', 'dav/files/test.txt', '', $john, ['Content-Type' => 'text/plain']);
+ $response->assertNoContent(204);
+ $response->assertHeader('ETag');
+
+ $this->assertSame('test.txt', $files[0]->getProperty('name'));
+ $this->assertSame('0', $files[0]->getProperty('size'));
+ // TODO: $this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
+ $this->assertSame('', $this->getTestFileContent($files[0]));
+
+ // Test updating a file in the root folder (non-empty file)
+ $response = $this->davRequest('PUT', 'dav/files/test.txt', 'Test222', $john, ['Content-Type' => 'text/plain']);
+ $response->assertNoContent(204);
+ $response->assertHeader('ETag');
+
+ $this->assertSame('test.txt', $files[0]->getProperty('name'));
+ $this->assertSame('7', $files[0]->getProperty('size'));
+ // TODO: $this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
+ $this->assertSame('Test222', $this->getTestFileContent($files[0]));
+
+ $files[0]->delete();
+ $folder = $this->getTestCollection($john, 'folder1');
+
+ // Test creating a file in custom folder (empty file)
+ $response = $this->davRequest('PUT', 'dav/files/folder1/test.txt', '', $john, ['Content-Type' => 'text/plain']);
+ $response->assertNoContent(201);
+ $response->assertHeader('ETag');
+
+ $this->assertCount(1, $files = $folder->children()->where('type', Item::TYPE_FILE)->get());
+ $this->assertSame('test.txt', $files[0]->getProperty('name'));
+ $this->assertSame('0', $files[0]->getProperty('size'));
+ // TODO: $this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
+ $this->assertSame('', $this->getTestFileContent($files[0]));
+
+ // Test updating a file in custom folder
+ $response = $this->davRequest('PUT', 'dav/files/folder1/test.txt', 'Test', $john, ['Content-Type' => 'text/plain']);
+ $response->assertNoContent(204);
+ $response->assertHeader('ETag');
+
+ $this->assertSame('test.txt', $files[0]->getProperty('name'));
+ $this->assertSame('4', $files[0]->getProperty('size'));
+ // TODO: $this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
+ $this->assertSame('Test', $this->getTestFileContent($files[0]));
+ }
+
+ /**
+ * Test basic PROPPATCH requests
+ */
+ public function testProppatch(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Do a HTTP request
+ */
+ protected function davRequest($method, $url, $xml = '', $user = null, $headers = [])
+ {
+ if ($xml && empty($headers['Content-Type']) && !str_starts_with($xml, '<?xml')) {
+ $xml = '<?xml version="1.0" encoding="utf-8" ?>' . $xml;
+ }
+
+ $default_headers = [
+ 'Content-Type' => 'text/xml; charset="utf-8"',
+ 'Content-Length' => strlen($xml),
+ 'Depth' => '1',
+ ];
+
+ if ($user) {
+ $default_headers['Authorization'] = 'Basic ' . base64_encode($user->email . ':' . \config('app.passphrase'));
+ }
+
+ $server = $this->transformHeadersToServerVars($headers + $default_headers);
+
+ // When testing Context is not being reset, we have to do this manually
+ // https://github.com/laravel/framework/issues/57776
+ Context::flush();
+
+ return $this->call($method, $url, [], [], [], $server, $xml);
+ }
+
+ /**
+ * Parse the response XML
+ */
+ protected function responseXML($response): \DOMDocument
+ {
+ $body = $response->baseResponse instanceof StreamedResponse ? $response->streamedContent() : $response->getContent();
+
+ if (empty($body)) {
+ $body = '<?xml version="1.0" encoding="utf-8" ?>';
+ }
+
+ $doc = new \DOMDocument('1.0', 'UTF-8');
+ $doc->loadXML($body);
+ $doc->formatOutput = true;
+
+ return $doc;
+ }
+
+ /**
+ * Initialize test folders/files in the storage
+ */
+ protected function initTestStorage(User $user): array
+ {
+ /*
+ /test1.txt
+ /test2.txt
+ /folder1/
+ /folder1/folder2/
+ /folder1/test3.txt
+ /folder1/test4.txt
+ */
+ $folders = $files = [];
+
+ $folders[] = $this->getTestCollection($user, 'folder1');
+ $folders[] = $this->getTestCollection($user, 'folder2');
+ $files[] = $this->getTestFile($user, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
+ $files[] = $this->getTestFile($user, 'test2.txt', '<html>Test con2</html>', ['mimetype' => 'text/html']);
+ $files[] = $this->getTestFile($user, 'test3.txt', 'Test content3', ['mimetype' => 'text/plain']);
+ $files[] = $this->getTestFile($user, 'test4.txt', '<p>Test con4</p>', ['mimetype' => 'text/html']);
+
+ $folders[0]->children()->attach($folders[1]);
+ $folders[0]->children()->attach($files[2]);
+ $folders[0]->children()->attach($files[3]);
+
+ return [$folders, $files];
+ }
+}
diff --git a/src/tests/Feature/Controller/FsTest.php b/src/tests/Feature/Controller/FsTest.php
--- a/src/tests/Feature/Controller/FsTest.php
+++ b/src/tests/Feature/Controller/FsTest.php
@@ -3,39 +3,13 @@
namespace Tests\Feature\Controller;
use App\Fs\Item;
-use App\Fs\Property;
-use App\Support\Facades\Storage;
use App\User;
use App\Utils;
use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\Storage as LaravelStorage;
-use Illuminate\Testing\TestResponse;
-use Tests\TestCase;
-
-/**
- * @group files
- */
-class FsTest extends TestCase
-{
- protected function setUp(): void
- {
- parent::setUp();
-
- Item::query()->forceDelete();
- }
-
- protected function tearDown(): void
- {
- Item::query()->forceDelete();
-
- $disk = LaravelStorage::disk(\config('filesystems.default'));
- foreach ($disk->listContents('') as $dir) {
- $disk->deleteDirectory($dir->path());
- }
-
- parent::tearDown();
- }
+use Tests\TestCaseFs;
+class FsTest extends TestCaseFs
+{
/**
* Test deleting items (DELETE /api/v4/fs/<item-id>)
*/
@@ -866,132 +840,4 @@
$this->assertSame(1, count($parents));
$this->assertSame($collection2->id, $parents->first()->id);
}
-
- /**
- * Create a test file.
- *
- * @param User $user File owner
- * @param string $name File name
- * @param string|array $content File content
- * @param array $props Extra file properties
- */
- protected function getTestFile(User $user, string $name, $content = [], $props = []): Item
- {
- $disk = LaravelStorage::disk(\config('filesystems.default'));
-
- $file = $user->fsItems()->create(['type' => Item::TYPE_FILE]);
- $size = 0;
-
- if (is_array($content) && empty($content)) {
- // do nothing, we don't need the body here
- } else {
- foreach ((array) $content as $idx => $chunk) {
- $chunkId = Utils::uuidStr();
- $path = Storage::chunkLocation($chunkId, $file);
-
- $disk->write($path, $chunk);
-
- $size += strlen($chunk);
-
- $file->chunks()->create([
- 'chunk_id' => $chunkId,
- 'sequence' => $idx,
- 'size' => strlen($chunk),
- ]);
- }
- }
-
- $properties = [
- 'name' => $name,
- 'size' => $size,
- 'mimetype' => 'application/octet-stream',
- ];
-
- $file->setProperties($props + $properties);
-
- return $file;
- }
-
- /**
- * Create a test collection.
- *
- * @param User $user File owner
- * @param string $name File name
- * @param array $props Extra collection properties
- */
- protected function getTestCollection(User $user, string $name, $props = []): Item
- {
- $collection = $user->fsItems()->create(['type' => Item::TYPE_COLLECTION]);
-
- $properties = [
- 'name' => $name,
- ];
-
- $collection->setProperties($props + $properties);
-
- return $collection;
- }
-
- /**
- * Get contents of a test file.
- *
- * @param Item $file File record
- */
- protected function getTestFileContent(Item $file): string
- {
- $content = '';
-
- $file->chunks()->orderBy('sequence')->get()->each(static function ($chunk) use ($file, &$content) {
- $disk = LaravelStorage::disk(\config('filesystems.default'));
- $path = Storage::chunkLocation($chunk->chunk_id, $file);
-
- $content .= $disk->read($path);
- });
-
- return $content;
- }
-
- /**
- * Create a test file permission.
- *
- * @param Item $file The file
- * @param User $user File owner
- * @param string $permission File permission
- *
- * @return Property File permission property
- */
- protected function getTestFilePermission(Item $file, User $user, string $permission): Property
- {
- $shareId = 'share-' . Utils::uuidStr();
-
- return $file->properties()->create([
- 'key' => $shareId,
- 'value' => "{$user->email}:{$permission}",
- ]);
- }
-
- /**
- * Invoke a HTTP request with a custom raw body
- *
- * @param ?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 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);
- }
- // TODO: Make sure this does not use "acting user" set earlier
- return $this->call($method, $uri, [], $cookies, [], $server, $content);
- }
}
diff --git a/src/tests/TestCaseFs.php b/src/tests/TestCaseFs.php
new file mode 100644
--- /dev/null
+++ b/src/tests/TestCaseFs.php
@@ -0,0 +1,164 @@
+<?php
+
+namespace Tests;
+
+use App\Fs\Item;
+use App\Fs\Property;
+use App\Support\Facades\Storage;
+use App\User;
+use App\Utils;
+use Illuminate\Support\Facades\Storage as LaravelStorage;
+use Illuminate\Testing\TestResponse;
+
+/**
+ * @group files
+ */
+class TestCaseFs extends TestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Item::query()->forceDelete();
+ }
+
+ protected function tearDown(): void
+ {
+ Item::query()->forceDelete();
+
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
+ foreach ($disk->listContents('') as $dir) {
+ $disk->deleteDirectory($dir->path());
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Create a test file.
+ *
+ * @param User $user File owner
+ * @param string $name File name
+ * @param string|array $content File content
+ * @param array $props Extra file properties
+ */
+ protected function getTestFile(User $user, string $name, $content = [], $props = []): Item
+ {
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
+
+ $file = $user->fsItems()->create(['type' => Item::TYPE_FILE]);
+ $size = 0;
+
+ if (is_array($content) && empty($content)) {
+ // do nothing, we don't need the body here
+ } else {
+ foreach ((array) $content as $idx => $chunk) {
+ $chunkId = Utils::uuidStr();
+ $path = Storage::chunkLocation($chunkId, $file);
+
+ $disk->write($path, $chunk);
+
+ $size += strlen($chunk);
+
+ $file->chunks()->create([
+ 'chunk_id' => $chunkId,
+ 'sequence' => $idx,
+ 'size' => strlen($chunk),
+ ]);
+ }
+ }
+
+ $properties = [
+ 'name' => $name,
+ 'size' => $size,
+ 'mimetype' => 'application/octet-stream',
+ ];
+
+ $file->setProperties($props + $properties);
+
+ return $file;
+ }
+
+ /**
+ * Create a test collection.
+ *
+ * @param User $user File owner
+ * @param string $name File name
+ * @param array $props Extra collection properties
+ */
+ protected function getTestCollection(User $user, string $name, $props = []): Item
+ {
+ $collection = $user->fsItems()->create(['type' => Item::TYPE_COLLECTION]);
+
+ $properties = [
+ 'name' => $name,
+ ];
+
+ $collection->setProperties($props + $properties);
+
+ return $collection;
+ }
+
+ /**
+ * Get contents of a test file.
+ *
+ * @param Item $file File record
+ */
+ protected function getTestFileContent(Item $file): string
+ {
+ $content = '';
+
+ $file->chunks()->orderBy('sequence')->get()->each(static function ($chunk) use ($file, &$content) {
+ $disk = LaravelStorage::disk(\config('filesystems.default'));
+ $path = Storage::chunkLocation($chunk->chunk_id, $file);
+
+ $content .= $disk->read($path);
+ });
+
+ return $content;
+ }
+
+ /**
+ * Create a test file permission.
+ *
+ * @param Item $file The file
+ * @param User $user File owner
+ * @param string $permission File permission
+ *
+ * @return Property File permission property
+ */
+ protected function getTestFilePermission(Item $file, User $user, string $permission): Property
+ {
+ $shareId = 'share-' . Utils::uuidStr();
+
+ return $file->properties()->create([
+ 'key' => $shareId,
+ 'value' => "{$user->email}:{$permission}",
+ ]);
+ }
+
+ /**
+ * Invoke a HTTP request with a custom raw body
+ *
+ * @param ?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 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);
+ }
+ // TODO: Make sure this does not use "acting user" set earlier
+ return $this->call($method, $uri, [], $cookies, [], $server, $content);
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 8:59 AM (16 h, 15 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828715
Default Alt Text
D5721.1775293154.diff (77 KB)

Event Timeline