Page MenuHomePhorge

D5721.1775241450.diff
No OneTemporary

Authored By
Unknown
Size
112 KB
Referenced Files
None
Subscribers
None

D5721.1775241450.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.
*
@@ -154,6 +181,15 @@
$fileSize = $disk->size($path);
+ // Pick the client-supplied mimetype if available, otherwise detect.
+ if (!empty($params['mimetype'])) {
+ $mimetype = $params['mimetype'];
+ } elseif (!$fileSize) {
+ $mimetype = 'application/x-empty';
+ } else {
+ $mimetype = self::mimetype($stream);
+ }
+
if ($file->type & Item::TYPE_INCOMPLETE) {
$file->type -= Item::TYPE_INCOMPLETE;
$file->save();
@@ -162,8 +198,7 @@
// Update the file type and size information
$file->setProperties([
'size' => $fileSize,
- // Pick the client-supplied mimetype if available, otherwise detect.
- 'mimetype' => !empty($params['mimetype']) ? $params['mimetype'] : self::mimetype($path),
+ 'mimetype' => $mimetype,
]);
// Assign the node to the file, "unlink" any old nodes of this file
@@ -245,7 +280,7 @@
// Detect file type using the first chunk
if ($from == 0) {
- $upload['mimetype'] = self::mimetype($path);
+ $upload['mimetype'] = self::mimetype($stream);
$upload['chunks'] = [];
}
@@ -297,22 +332,18 @@
/**
* Get the file mime type.
*
- * @param string $path File location
+ * @param resource $stream File stream
*
* @return string File mime type
*/
- protected static function mimetype(string $path): string
+ protected static function mimetype($stream): string
{
- $disk = LaravelStorage::disk(\config('filesystems.default'));
+ rewind($stream);
- $mimetype = $disk->mimeType($path);
-
- // The mimetype may contain e.g. "; charset=UTF-8", remove this
- if ($mimetype) {
- return explode(';', $mimetype)[0];
- }
+ $detector = new \League\MimeTypeDetection\FinfoMimeTypeDetector();
+ $mimetype = $detector->detectMimeTypeFromBuffer(stream_get_contents($stream, 1024 * 1024));
- return 'application/octet-stream';
+ return $mimetype ?: 'application/octet-stream';
}
/**
diff --git a/src/app/Console/Commands/DB/ExpungeCommand.php b/src/app/Console/Commands/DB/ExpungeCommand.php
--- a/src/app/Console/Commands/DB/ExpungeCommand.php
+++ b/src/app/Console/Commands/DB/ExpungeCommand.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands\DB;
+use App\Fs\Lock;
use App\Policy\Greylist\Connect;
use App\Policy\Greylist\Whitelist;
use App\Policy\RateLimit;
@@ -48,6 +49,10 @@
DB::table('failed_jobs')->where('failed_at', '<', Carbon::now()->subMonthsWithoutOverflow(6))
->delete();
+ // Remove expired filesystem locks
+ Lock::where('timeout', '>=', '0')->whereRaw('created_at > now() - interval timeout second')
+ ->delete();
+
// TODO: What else? Should we force-delete deleted "dummy/spammer" accounts?
}
}
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,52 @@
return $this->hasMany(Chunk::class);
}
+ /**
+ * Copy the item to another location
+ *
+ * @param ?self $target Target folder
+ * @param ?string $name Optional name (for rename)
+ *
+ * @return self Created copy item
+ */
+ public function copy(?self $target, ?string $name = null): self
+ {
+ // 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 file/folder contents
+ if ($this->isFile()) {
+ Storage::fileCopy($this, $copy);
+ } else {
+ $this->children()->get()->each(function ($item) use ($copy) {
+ $item->copy($copy);
+ });
+ }
+
+ return $copy;
+ }
+
/**
* Getter for the file path (without the filename) in the storage.
*/
@@ -112,6 +159,51 @@
return $props;
}
+ /**
+ * Check if the item is a collection (folder)
+ */
+ public function isCollection(): bool
+ {
+ return (bool) ($this->type & self::TYPE_COLLECTION);
+ }
+
+ /**
+ * Check if the item is a file
+ */
+ public function isFile(): bool
+ {
+ return (bool) ($this->type & self::TYPE_FILE);
+ }
+
+ /**
+ * Check if the item is incomplete
+ */
+ public function isIncomplete(): bool
+ {
+ return (bool) ($this->type & self::TYPE_INCOMPLETE);
+ }
+
+ /**
+ * 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
*
@@ -176,6 +268,16 @@
return $this->hasMany(Relation::class);
}
+ /**
+ * All locks for this item
+ *
+ * @return HasMany<Lock, $this>
+ */
+ public function locks()
+ {
+ return $this->hasMany(Lock::class);
+ }
+
/**
* Child relations for this item
*
diff --git a/src/app/Fs/Lock.php b/src/app/Fs/Lock.php
new file mode 100644
--- /dev/null
+++ b/src/app/Fs/Lock.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Fs;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Sabre\DAV\Locks\LockInfo;
+
+/**
+ * The eloquent definition of a filesystem lock.
+ *
+ * @property int $depth Lock depth (0 or -1)
+ * @property int $id Lock identifier
+ * @property string $item_id Item identifier
+ * @property string $owner Lock owner information
+ * @property int $scope Lock scope (1 or 2)
+ * @property int $timeout Lock timeout (in seconds, or -1 for infinite)
+ * @property string $token Lock token
+ */
+class Lock extends Model
+{
+ public const DEPTH_INFINITY = \Sabre\DAV\Server::DEPTH_INFINITY;
+ public const SCOPE_SHARED = LockInfo::SHARED;
+ public const SCOPE_EXCLUSIVE = LockInfo::EXCLUSIVE;
+ public const TIMEOUT_INFINITE = LockInfo::TIMEOUT_INFINITE;
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'depth' => 'integer',
+ 'scope' => 'integer',
+ 'timeout' => 'integer',
+ ];
+
+ /** @var list<string> The attributes that are mass assignable */
+ protected $fillable = ['depth', 'item_id', 'token', 'scope', 'timeout', 'owner'];
+
+ /** @var string Database table name */
+ protected $table = 'fs_locks';
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+
+ /**
+ * The filesystem item the lock is on.
+ *
+ * @return BelongsTo<Item, $this>
+ */
+ public function item()
+ {
+ return $this->belongsTo(Item::class);
+ }
+}
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/API/V4/FsController.php b/src/app/Http/Controllers/API/V4/FsController.php
--- a/src/app/Http/Controllers/API/V4/FsController.php
+++ b/src/app/Http/Controllers/API/V4/FsController.php
@@ -49,7 +49,7 @@
// storage later with the fs:expunge command
$file->delete();
- if ($file->type & Item::TYPE_COLLECTION) {
+ if ($file->isCollection()) {
$message = self::trans('app.collection-delete-success');
}
@@ -468,7 +468,7 @@
return $this->errorResponse($file);
}
- if ($file->type == Item::TYPE_COLLECTION) {
+ if ($file->isCollection()) {
// Updating a collection is not supported yet
return $this->errorResponse(405);
}
@@ -748,7 +748,7 @@
$file = Item::find($fileId);
- if (!$file) {
+ if (!$file || $file->isIncomplete()) {
return 404;
}
@@ -756,10 +756,6 @@
return 403;
}
- if ($file->type & Item::TYPE_FILE && $file->type & Item::TYPE_INCOMPLETE) {
- return 404;
- }
-
return $file;
}
@@ -774,9 +770,9 @@
protected function objectToClient($object, bool $full = false): array
{
$result = ['id' => $object->id];
- if ($object->type & Item::TYPE_COLLECTION) {
+ if ($object->isCollection()) {
$result['type'] = self::TYPE_COLLECTION;
- } elseif ($object->type & Item::TYPE_FILE) {
+ } elseif ($object->isFile()) {
$result['type'] = self::TYPE_FILE;
} else {
$result['type'] = self::TYPE_UNKNOWN;
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,83 @@
+<?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::match(
+ [
+ // Standard HTTP methods
+ 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT',
+ // WebDAV specific methods
+ 'MOVE', 'COPY', 'MKCOL', 'PROPFIND', 'PROPPATCH', 'REPORT', 'LOCK', 'UNLOCK',
+ ],
+ $root . '/user/{email}/{path?}',
+ [self::class, 'run']
+ )
+ ->where('path', '.*') // This makes 'path' to match also sub-paths
+ ->name('dav');
+ }
+
+ /**
+ * Handle a WebDAV request
+ */
+ public function run(Request $request, string $email): Response|StreamedResponse
+ {
+ $root = trim(\config('services.dav.webdav_root'), '/');
+
+ $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('/' . $root . '/user/' . $email);
+ $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,72 @@
+<?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
+ {
+ // Note: For now authenticating user must match the path user
+
+ if (str_contains($username, '@') && $username === $this->getPathUser()) {
+ $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;
+ }
+
+ /**
+ * Extract user (email) from the request path.
+ */
+ protected static function getPathUser(): string
+ {
+ $path = \request()->path();
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/';
+ $path = substr($path, strlen($root));
+
+ return explode('/', $path)[0];
+ }
+}
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,366 @@
+<?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\INodeByPath;
+use Sabre\DAV\IProperties;
+
+/**
+ * Sabre DAV Collection interface implemetation
+ */
+class Collection extends Node implements ICollection, ICopyTarget, IMoveTarget, INodeByPath, 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
+
+ // TODO: Missing Depth:X handling. See also https://github.com/sabre-io/dav/pull/1495
+
+ $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?
+ 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: This may not be optimal for a case with a lot of files/folders
+ // TODO: Maybe deleting a folder contents should be moved to a delete event observer
+ $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)
+ {
+ return $this->getNodeForPath($name);
+ }
+
+ /**
+ * Returns the INode object for the requested path.
+ *
+ * In case where this collection can not retrieve the requested node
+ * but also can not determine that the node does not exists,
+ * null should be returned to signal that the caller should fallback
+ * to walking the directory tree.
+ *
+ * @param string $name Node name (can include path)
+ *
+ * @return INode|null
+ *
+ * @throws \Exception
+ */
+ public function getNodeForPath($name)
+ {
+ $path = $this->nodePath($name);
+
+ \Log::debug("[DAV] GET-NODE-FOR-PATH: {$path}");
+
+ if (str_starts_with($name, '.') || str_contains($name, '/.')) {
+ throw new Exception\NotFound("File not found: {$path}");
+ }
+
+ $item = $this->fsItemForPath($path);
+
+ $class = $item->type == Item::TYPE_COLLECTION ? self::class : File::class;
+ $parent = $this;
+ $parent_path = preg_replace('|/[^/]+$|', '', $path);
+
+ if ($parent_path != $this->path) {
+ $parent = $this->fsItemForPath($parent_path);
+ }
+
+ return new $class($path, $parent, $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,168 @@
+<?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;
+ });
+
+ $this->deleteCachedItem($this->path);
+ }
+}
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/Locks.php b/src/app/Http/DAV/Locks.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/Locks.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Http\DAV;
+
+use App\Fs\Item;
+use App\Fs\Lock;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+use Sabre\DAV\Exception;
+use Sabre\DAV\Locks\Backend\AbstractBackend;
+use Sabre\DAV\Locks\LockInfo;
+
+/**
+ * The Lock manager allows you to handle all file-locks centrally.
+ */
+class Locks extends AbstractBackend
+{
+ /**
+ * Returns a list of Sabre\DAV\Locks\LockInfo objects.
+ *
+ * This method should return all the locks for a particular uri, including
+ * locks that might be set on a parent uri.
+ *
+ * If returnChildLocks is set to true, this method should also look for
+ * any locks in the subtree of the uri for locks.
+ *
+ * @param string $uri
+ * @param bool $returnChildLocks
+ *
+ * @return array<LockInfo> List of locks
+ */
+ public function getLocks($uri, $returnChildLocks = false)
+ {
+ \Log::debug('[DAV] GET-LOCKS: ' . $uri);
+
+ // Note: We're disabling exceptions here, otherwise it has unwanted effects
+ // in places where Sabre checks locks on non-existing paths
+ $ids = Node::resolvePath($uri, true);
+
+ if (empty($ids)) {
+ return [];
+ }
+
+ // Note: If any node in the path does not exist returned array will contain only
+ // existing ones
+ $all_count = substr_count($uri, '/') + 1;
+ $id = null;
+ if ($exists = $all_count == count($ids)) {
+ $id = array_pop($ids);
+ }
+
+ $locks = Lock::select()
+ ->whereRaw('created_at > now() - interval timeout second or timeout = -1')
+ ->where(function (Builder $query) use ($id, $ids) {
+ if ($id) {
+ $query->where('item_id', $id);
+ }
+
+ if (!empty($ids)) {
+ $query->orWhere(function (Builder $query) use ($ids) {
+ $query->whereIn('item_id', $ids)->whereNot('depth', 0);
+ });
+ }
+ })
+ ->get();
+
+ if ($exists && $returnChildLocks) {
+ // Include locks for all children of $id, and their children, and so on
+ // TODO: It could be skipped if $id node is not a collection
+ $add = Lock::select('fs_locks.*')->distinct()
+ ->whereRaw('created_at > now() - interval timeout second or timeout = -1')
+ ->join(
+ DB::raw(
+ '(with recursive children as ('
+ . "select related_id from fs_relations where item_id = '{$id}'"
+ . ' union all'
+ . ' select r2.related_id from fs_relations r2 inner join children c where r2.item_id = c.related_id'
+ . ')'
+ . ' select related_id as id from children) as items',
+ ),
+ 'items.id',
+ '=',
+ 'fs_locks.item_id'
+ )
+ ->get();
+
+ $locks = $locks->merge($add);
+ }
+
+ $path = explode('/', $uri);
+ $uri_map = $id ? [$id => $uri] : [];
+ foreach ($ids as $i => $node_id) {
+ $uri_map[$node_id] = implode('/', array_slice($path, 0, $i + 1));
+ }
+
+ return $locks->map(function ($lock) use ($uri_map) {
+ $lock_uri = $uri_map[$lock->item_id] ?? null;
+ if ($lock_uri === null) {
+ // This is for the $uri's children
+ // Note: This assumes a node has only one parent
+ // FIXME: Some optimization of this process would be nice (e.g. done via the big query above)
+ $item = Item::find($lock->item_id);
+ $lock_uri = '/' . $item->getProperty('name');
+ while ($parent = $item->parents()->first()) {
+ if (isset($uri_map[$parent->id])) {
+ $uri_map[$item->id] = $lock_uri = $uri_map[$parent->id] . $lock_uri;
+ break;
+ }
+ $lock_uri = '/' . $parent->getProperty('name') . $lock_uri;
+ $item = $parent;
+ }
+ }
+
+ $lockInfo = new LockInfo();
+ $lockInfo->owner = $lock->owner;
+ $lockInfo->token = $lock->token;
+ $lockInfo->timeout = $lock->timeout;
+ $lockInfo->created = $lock->created_at->getTimestamp();
+ $lockInfo->scope = $lock->scope;
+ $lockInfo->depth = $lock->depth;
+ $lockInfo->uri = $lock_uri;
+
+ return $lockInfo;
+ })
+ ->all();
+ }
+
+ /**
+ * Locks a uri.
+ *
+ * @param string $uri
+ *
+ * @return bool
+ *
+ * @throws \Exception
+ */
+ public function lock($uri, LockInfo $lockInfo)
+ {
+ \Log::debug('[DAV] LOCK: ' . $uri);
+
+ if (!strlen($uri)) {
+ throw new Exception\Forbidden("Cannot lock the root");
+ }
+
+ // We're making the lock timeout 30 minutes
+ $lockInfo->timeout = 30 * 60;
+ $lockInfo->created = time();
+
+ $ids = Node::resolvePath($uri);
+ $item_id = array_pop($ids);
+
+ Lock::upsert(
+ [[
+ 'item_id' => $item_id,
+ 'depth' => $lockInfo->depth,
+ 'owner' => trim((string) $lockInfo->owner),
+ 'scope' => $lockInfo->scope,
+ 'timeout' => $lockInfo->timeout,
+ 'token' => $lockInfo->token,
+ 'created_at' => \now(),
+ ]],
+ ['item_id', 'token'],
+ // Note: As far as I can see a lock update is used to refresh a lock,
+ // in sach case only creation time property is expected to be updated.
+ [/* 'scope', 'depth', 'timeout', */ 'created_at']
+ );
+
+ return true;
+ }
+
+ /**
+ * Removes a lock from a uri.
+ *
+ * @param string $uri Node location
+ * @param LockInfo $lockInfo Lock information (e.g. token)
+ *
+ * @return bool
+ */
+ public function unlock($uri, LockInfo $lockInfo)
+ {
+ \Log::debug('[DAV] UNLOCK: ' . $uri);
+
+ $ids = Node::resolvePath($uri);
+ $item_id = array_pop($ids);
+
+ return Lock::where('item_id', $item_id)->where('token', $lockInfo->token)->delete() === 1;
+ }
+}
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,263 @@
+<?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'), '/') . '/user/' . Auth::$user?->email;
+
+ 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);
+ }
+
+ /**
+ * Get item identifiers for all items in a path
+ *
+ * @param string $path Node location
+ * @param bool $nothrow Don't throw NotFound exception, return as many nodes as possible
+ *
+ * @return array<string> List of item identifiers
+ *
+ * @throws Exception\NotFound For not found non-root folder/file
+ */
+ public static function resolvePath(string $path, $nothrow = false): array
+ {
+ if (!strlen($path)) {
+ if ($nothrow) {
+ return [];
+ }
+
+ throw new Exception\NotFound("Unsupported location");
+ }
+
+ $path = explode('/', $path);
+ $count = count($path);
+ $result = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $item_path = implode('/', array_slice($path, 0, $i + 1));
+
+ try {
+ $item = self::fsItemForPath($item_path);
+ } catch (Exception\NotFound $e) {
+ if ($nothrow) {
+ return $result;
+ }
+
+ throw $e;
+ }
+
+ $result[] = $item->id;
+ }
+
+ return $result;
+ }
+
+ /**
+ * 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 static function fsItemForPath(string $path): ?Item
+ {
+ if (!strlen($path)) {
+ return null;
+ }
+
+ if (($item = self::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 = self::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
+ // TODO: In some requests context (e.g. LOCK/UNLOCK) we don't need these extra 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) {
+ self::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 static 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 static 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 && !feof($body)) {
+ $output = fread($body, min($length, 10 * 1024 * 1024));
+ $length -= strlen($output);
+ echo $output;
+ }
+ }
+ 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,7 +21,10 @@
];
// Exclude horizon routes, per https://github.com/laravel/horizon/issues/576
- if ($request->is('horizon*')) {
+ // Exclude WebDAV routes, as it is a service not for a web browser
+ $dav_prefix = trim(\config('services.dav.webdav_root'), '/') . '/user/*';
+
+ if ($request->is('horizon*') || $request->is($dav_prefix)) {
$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),
+
+ // Kolab3 used just /files, Cyrus DAV uses /dav/drive/user/{email}.
+ // We chose /dav/files/user/{email}, for consistency with CalDAV/CardDAV.
+ 'webdav_root' => env('DAV_WEBDAV_ROOT', 'dav/files'),
],
'imap' => [
diff --git a/src/database/migrations/2025_11_21_100000_create_fs_locks_table.php b/src/database/migrations/2025_11_21_100000_create_fs_locks_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2025_11_21_100000_create_fs_locks_table.php
@@ -0,0 +1,41 @@
+<?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_locks',
+ static function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('item_id', 36);
+ $table->string('owner', 512);
+ $table->binary('token', 100); // VARBINARY
+ $table->integer('timeout');
+ $table->tinyInteger('scope');
+ $table->tinyInteger('depth');
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->unique(['item_id', 'token']);
+ $table->index(['created_at', 'timeout']);
+
+ $table->foreign('item_id')->references('id')->on('fs_items')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('fs_locks');
+ }
+};
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,967 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Fs\Item;
+use App\Fs\Lock;
+use App\User;
+use Carbon\Carbon;
+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'), '/');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+ $john = $this->getTestUser('john@kolab.org');
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('COPY', "{$root}/test1.txt", '', null, ['Destination' => "{$host}/{$root}/copied1.txt"]);
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test copying a non-existing file
+ $response = $this->davRequest('COPY', "{$root}/unknown", '', $john, ['Destination' => "{$host}/{$root}/copied.txt"]);
+ $response->assertNoContent(404);
+
+ // Test a file copy into non-existing location
+ $response = $this->davRequest('COPY', "{$root}/test1.txt", '', $john, ['Destination' => "{$host}/{$root}/unknown/test1.txt"]);
+ $response->assertNoContent(409);
+
+ // Test a file copy "in place" with rename
+ $response = $this->davRequest('COPY', "{$root}/test1.txt", '', $john, ['Destination' => "{$host}/{$root}/copied1.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('copied1.txt', $file->getProperty('name'));
+ $this->assertSame('text/plain', $file->getProperty('mimetype'));
+ $this->assertSame('13', $file->getProperty('size'));
+ $all_items[] = $file->id;
+ $copied = $file;
+
+ // 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', "{$root}/folder-empty", '', $john, ['Destination' => "{$host}/{$root}/folder-copy", 'Depth' => 'infinity']);
+ $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->assertSame('folder-copy', $folder->getProperty('name'));
+ $this->assertCount(0, $folder->parents()->get());
+
+ // Copying non-empty folders
+ // Add an extra file into /folder1/folder2 folder
+ $file = $this->getTestFile($john, 'test5.txt', 'Test con5', ['mimetype' => 'text/plain']);
+ $folders[1]->children()->attach($file);
+
+ $response = $this->davRequest('COPY', "{$root}/folder1", '', $john, ['Destination' => "{$host}/{$root}/folder-copy/folder1", 'Depth' => 'infinity']);
+ $response->assertNoContent(201);
+
+ $this->assertCount(1, $children = $folder->children()->get());
+ $copy = $children[0];
+ $this->assertSame(Item::TYPE_COLLECTION, $copy->type);
+ $this->assertSame('folder1', $copy->getProperty('name'));
+ $this->assertCount(1, $copy->parents()->get());
+ $children = $copy->children()->select('fs_items.*')
+ ->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
+ . ' and fs_properties.key = \'name\') as name')
+ ->orderBy('name')
+ ->get()
+ ->keyBy('name');
+ $this->assertSame(['folder2', 'test3.txt', 'test4.txt'], $children->pluck('name')->all());
+ $children = $children['folder2']->children()->select('fs_items.*')
+ ->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
+ . ' and fs_properties.key = \'name\') as name')
+ ->orderBy('name')
+ ->get()
+ ->keyBy('name');
+ $this->assertSame(['test5.txt'], $children->pluck('name')->all());
+ $this->assertSame('Test con5', $this->getTestFileContent($children['test5.txt']));
+
+ // Copy a file into an existing location (with "Overwrite:F" header)
+ $response = $this->davRequest('COPY', "{$root}/test2.txt", '', $john, ['Destination' => "{$host}/{$root}/copied1.txt", 'Overwrite' => 'F']);
+ $response->assertStatus(412);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Copy a file into an existing location (without "Overwrite:F" header)
+ $response = $this->davRequest('COPY', "{$root}/folder1/folder2/test5.txt", '', $john, ['Destination' => "{$host}/{$root}/copied1.txt"]);
+ $response->assertNoContent(204);
+
+ $this->assertTrue($copied->fresh()->trashed());
+ $copied = $john->fsItems()
+ ->whereRaw('id in (select item_id from fs_properties where `key` = \'name\' and `value` = \'copied1.txt\')')
+ ->get();
+ $this->assertCount(1, $copied);
+ $this->assertTrue($children['test5.txt']->id != $copied[0]->id);
+ $this->assertSame('copied1.txt', $copied[0]->getProperty('name'));
+ $this->assertSame('Test con5', $this->getTestFileContent($copied[0]));
+
+ // TODO: Test copying a collection with Depth:0 (and Depth:1) header
+ // TODO: Make sure a copy /A/ into /A/B/ does not lead to an infinite recursion
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test basic DELETE requests
+ */
+ public function testDelete(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/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', "{$root}/test1.txt");
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test non-existing location
+ $response = $this->davRequest('DELETE', "{$root}/unknown", '', $john);
+ $response->assertNoContent(404);
+
+ // Test deleting a file in the root
+ $response = $this->davRequest('DELETE', "{$root}/test1.txt", '', $john);
+ $response->assertNoContent(204);
+
+ $this->assertTrue($files[0]->fresh()->trashed());
+
+ // Test deleting a file in a folder
+ $response = $this->davRequest('DELETE', "{$root}/folder1/test3.txt", '', $john);
+ $response->assertNoContent(204);
+
+ $this->assertTrue($files[2]->fresh()->trashed());
+
+ // Test deleting a folder
+ $response = $this->davRequest('DELETE', "{$root}/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', $root, '', $john);
+ $response->assertNoContent(403);
+
+ $this->assertFalse($files[1]->fresh()->trashed());
+ }
+
+ /**
+ * Test basic DELETE requests to locked nodes
+ */
+ public function testDeleteLocked(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ // Create the following structure
+ // - folder1/
+ // - folder2/
+ // - test1.txt
+ // - test2.txt
+
+ $folder1 = $this->getTestCollection($john, 'folder1');
+ $folder2 = $this->getTestCollection($john, 'folder2');
+ $file1 = $this->getTestFile($john, 'test1.txt', 'Test');
+ $file2 = $this->getTestFile($john, 'test2.txt', 'Test');
+
+ $folder1->children()->attach($folder2);
+ $folder2->children()->attach([$file1, $file2]);
+
+ $lock1 = $folder1->locks()->create([
+ 'token' => 'testtoken',
+ 'depth' => 0,
+ 'scope' => Lock::SCOPE_SHARED,
+ 'timeout' => 1800,
+ 'owner' => 'test',
+ ]);
+
+ // Test deleting a locked folder
+ $response = $this->davRequest('DELETE', "{$root}/folder1", '', $john);
+ $response->assertStatus(423);
+
+ // TODO: Assert XML response
+ $this->assertFalse($folder1->fresh()->trashed());
+
+ // Test deleting a file in a locked folder
+ $response = $this->davRequest('DELETE', "{$root}/folder1/folder2/test1.txt", '', $john);
+ $response->assertStatus(423);
+
+ // TODO: Assert XML response
+ $this->assertFalse($file1->fresh()->trashed());
+
+ // Test deleting a folder that has a locked child
+ $lock1->delete();
+ $lock2 = $file1->locks()->create([
+ 'token' => 'testtoken',
+ 'depth' => 0,
+ 'scope' => Lock::SCOPE_SHARED,
+ 'timeout' => 1800,
+ 'owner' => 'test',
+ ]);
+ $response = $this->davRequest('DELETE', "{$root}/folder1", '', $john);
+ $response->assertStatus(423);
+
+ // TODO: Assert XML response
+ $this->assertFalse($folder1->fresh()->trashed());
+
+ // TODO: Test successful deletion with 'If' header
+ }
+
+ /**
+ * Test basic GET requests
+ */
+ public function testGet(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/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', "{$root}/test1.txt");
+ $response->assertStatus(401);
+ $response->assertHeaderMissing('Content-Security-Policy');
+ $response->assertHeader('WWW-Authenticate', 'Basic realm="Kolab/DAV", charset="UTF-8"');
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test non-existing location
+ $response = $this->davRequest('GET', "{$root}/unknown", '', $john);
+ $response->assertNoContent(404);
+
+ // Test with a valid Authorization header
+ $response = $this->davRequest('GET', "{$root}/test1.txt", '', $john);
+ $response->assertStatus(200);
+ $response->assertStreamedContent(implode('', $content));
+
+ // Test Range header
+ $range = 'bytes=0-' . $content_length - 1;
+ $response = $this->davRequest('GET', "{$root}/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', "{$root}/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', "{$root}/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', "{$root}/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);
+
+ // Test GET on a collection
+ $folder = $this->getTestCollection($john, 'folder1');
+ $response = $this->davRequest('GET', "{$root}/folder1", '', $john);
+ $response->assertStatus(501);
+
+ // TODO: Test big files >10MB
+ }
+
+ /**
+ * Test basic HEAD requests
+ */
+ public function testHead(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+ $file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('HEAD', "{$root}/test1.txt");
+ $response->assertNoContent(401);
+
+ // Test non-existing location
+ $response = $this->davRequest('HEAD', "{$root}/unknown", '', $john);
+ $response->assertNoContent(404);
+
+ // Test with a valid Authorization header
+ $response = $this->davRequest('HEAD', "{$root}/test1.txt", '', $john);
+ $response->assertNoContent(200);
+
+ // TODO: Test HEAD on a collection
+ }
+
+ /**
+ * Test basic LOCK requests (RFC 4918)
+ */
+ public function testLock(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+ $file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
+
+ $xml = <<<EOF
+ <d:lockinfo xmlns:d='DAV:'>
+ <d:lockscope><d:exclusive/></d:lockscope>
+ <d:locktype><d:write/></d:locktype>
+ <d:owner>
+ <d:href>{$root}</d:href>
+ </d:owner>
+ </d:lockinfo>
+ EOF;
+
+ // Test with no Authorization header
+ $response = $this->davRequest('LOCK', "{$root}/test1.txt", $xml);
+ $response->assertNoContent(401);
+
+ // Test locking the root
+ $response = $this->davRequest('LOCK', $root, $xml, $john);
+ $response->assertStatus(403);
+
+ // Test locking an existing file
+ $response = $this->davRequest('LOCK', "{$root}/test1.txt", $xml, $john);
+ $response->assertStatus(200);
+
+ $lock = $file->locks()->first();
+ $this->assertSame("<opaquelocktoken:{$lock->token}>", $response->headers->get('Lock-Token'));
+ $this->assertSame("<d:href xmlns:d=\"DAV:\">{$root}</d:href>", $lock->owner);
+ $this->assertSame(1, $lock->depth);
+ $this->assertSame(Lock::SCOPE_EXCLUSIVE, $lock->scope);
+ $this->assertSame(1800, $lock->timeout);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('prop', $doc->documentElement->localName);
+ $data = $doc->documentElement->getElementsByTagName('lockdiscovery')->item(0)
+ ->getElementsByTagName('activelock')->item(0);
+ $this->assertSame('write', $data->getElementsByTagName('locktype')->item(0)->firstChild->localName);
+ $this->assertSame('exclusive', $data->getElementsByTagName('lockscope')->item(0)->firstChild->localName);
+ $this->assertSame('1', $data->getElementsByTagName('depth')->item(0)->nodeValue);
+ $this->assertSame($root, $data->getElementsByTagName('owner')->item(0)->firstChild->nodeValue);
+ $this->assertSame('Second-1800', $data->getElementsByTagName('timeout')->item(0)->nodeValue);
+ $this->assertSame("opaquelocktoken:{$lock->token}", $data->getElementsByTagName('locktoken')->item(0)->firstChild->nodeValue);
+ $this->assertSame("/{$root}/test1.txt", $data->getElementsByTagName('lockroot')->item(0)->firstChild->nodeValue);
+
+ // Test updating a lock (non-empty body)
+ $response = $this->davRequest('LOCK', "{$root}/test1.txt", $xml, $john);
+ $response->assertStatus(423);
+ // TODO: Assert XML response
+
+ // Test updating a lock - empty body as specified in RFC, but missing If header
+ $response = $this->davRequest('LOCK', "{$root}/test1.txt", '', $john);
+ $response->assertStatus(423);
+ // TODO: Assert XML response
+
+ // Test updating a lock - empty body as specified in RFC, with valid If header
+ Carbon::setTestNow(Carbon::createFromDate(2022, 2, 2));
+ $headers = ['Depth' => '0', 'If' => "(<opaquelocktoken:{$lock->token}>)"];
+ $response = $this->davRequest('LOCK', "{$root}/test1.txt", '', $john, $headers);
+ $response->assertStatus(200);
+
+ $lock->refresh();
+ $this->assertSame("<opaquelocktoken:{$lock->token}>", $response->headers->get('Lock-Token'));
+ $this->assertSame("<d:href xmlns:d=\"DAV:\">{$root}</d:href>", $lock->owner);
+ $this->assertSame(1, $lock->depth);
+ $this->assertSame(Lock::SCOPE_EXCLUSIVE, $lock->scope);
+ $this->assertSame(1800, $lock->timeout);
+ $this->assertSame('2022-02-02', $lock->created_at->format('Y-m-d'));
+
+ // Test non-existing location (expect an empty file created)
+ $xml = <<<'EOF'
+ <d:lockinfo xmlns:d='DAV:'>
+ <d:lockscope><d:shared/></d:lockscope>
+ <d:locktype><d:write/></d:locktype>
+ <d:owner>Test Owner</d:owner>
+ </d:lockinfo>
+ EOF;
+ $headers = ['Depth' => 'infinity', 'Timeout' => 'Infinite, Second-4100000000'];
+ $response = $this->davRequest('LOCK', "{$root}/unknown", $xml, $john, $headers);
+ $response->assertStatus(201);
+
+ $doc = $this->responseXML($response);
+ $file2 = $john->fsItems()->whereNot('id', $file->id)->first();
+ $this->assertSame('unknown', $file2->getProperty('name'));
+ $this->assertSame('0', $file2->getProperty('size'));
+ $this->assertSame('application/x-empty', $file2->getProperty('mimetype'));
+
+ $lock2 = $file2->locks()->first();
+ $this->assertSame("<opaquelocktoken:{$lock2->token}>", $response->headers->get('Lock-Token'));
+ $this->assertSame('Test Owner', $lock2->owner);
+ $this->assertSame(Lock::DEPTH_INFINITY, $lock2->depth);
+ $this->assertSame(Lock::SCOPE_SHARED, $lock2->scope);
+ $this->assertSame(1800, $lock2->timeout);
+
+ $this->assertSame('prop', $doc->documentElement->localName);
+ $data = $doc->documentElement->getElementsByTagName('lockdiscovery')->item(0)
+ ->getElementsByTagName('activelock')->item(0);
+ $this->assertSame('shared', $data->getElementsByTagName('lockscope')->item(0)->firstChild->localName);
+ $this->assertSame('infinity', $data->getElementsByTagName('depth')->item(0)->nodeValue);
+ $this->assertSame('Test Owner', $data->getElementsByTagName('owner')->item(0)->nodeValue);
+ $this->assertSame('Second-1800', $data->getElementsByTagName('timeout')->item(0)->nodeValue);
+ $this->assertSame("opaquelocktoken:{$lock2->token}", $data->getElementsByTagName('locktoken')->item(0)->firstChild->nodeValue);
+ $this->assertSame("/{$root}/unknown", $data->getElementsByTagName('lockroot')->item(0)->firstChild->nodeValue);
+
+ // TODO: Test lock conflicts
+ }
+
+ /**
+ * Test various requests to locked nodes
+ */
+ public function testLockFeatures(): void
+ {
+ // TODO: PROPFIND with lock related properties
+ // TODO: Test MOVE on a locked file
+ // TODO: Test COPY on a locked file
+ // TODO: PUT/PATCH on a locked file
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Test basic MKCOL requests
+ */
+ public function testMkcol(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ // Test with no Authorization header
+ $response = $this->davRequest('MKCOL', "{$root}/folder1");
+ $response->assertStatus(401);
+
+ // Test creating a collection in the root
+ $response = $this->davRequest('MKCOL', "{$root}/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', "{$root}/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', "{$root}/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');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('MOVE', "{$root}/test1.txt", '', null, ['Destination' => "{$host}/{$root}/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', "{$root}/unknown", '', $john, ['Destination' => "{$host}/{$root}/moved.txt"]);
+ $response->assertNoContent(404);
+
+ // Test a file rename
+ $response = $this->davRequest('MOVE', "{$root}/test1.txt", '', $john, ['Destination' => "{$host}/{$root}/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', "{$root}/folder1", '', $john, ['Destination' => "{$host}/{$root}/folder10", 'Depth' => 'infinity']);
+ $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', "{$root}/folder10/folder2", '', $john, ['Destination' => "{$host}/{$root}/folder20", 'Depth' => 'infinity']);
+ $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', "{$root}/folder10/test3.txt", '', $john, ['Destination' => "{$host}/{$root}/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 another folder (no rename)
+ $response = $this->davRequest('MOVE', "{$root}/folder20", '', $john, ['Destination' => "{$host}/{$root}/folder10/folder20", 'Depth' => 'infinity']);
+ $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', "{$root}/test2.txt", '', $john, ['Destination' => "{$host}/{$root}/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 moving into an existing location with Overwrite:F header
+ $response = $this->davRequest('MOVE', "{$root}/moved1.txt", '', $john, ['Destination' => "{$host}/{$root}/test30.txt", 'Overwrite' => 'F']);
+ $response->assertStatus(412);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+ }
+
+ /**
+ * Test basic OPTIONS requests
+ */
+ public function testOptions(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/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', $root);
+ $response->assertStatus(401);
+
+ // Test with valid Authorization header
+ $response = $this->davRequest('OPTIONS', $root, '', $john);
+ $response->assertNoContent(200);
+
+ // TODO: Verify the supported feature set
+ $this->assertSame('1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, 2', $response->headers->get('DAV'));
+ }
+
+ /**
+ * Test basic PROPFIND requests on the root location
+ */
+ public function testPropfindOnTheRootFolder(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PROPFIND', $root, '<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', "{$root}/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', $root, '<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("/{$root}/", $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("/{$root}/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("/{$root}/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("/{$root}/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', $root, '<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("/{$root}/", $doc->getElementsByTagName('href')->item(0)->textContent);
+
+ // Test that Depth:infinity is not supported
+ // FIXME: Seems Sabre falls back to Depth:1 and does not respond with an error
+ $response = $this->davRequest('PROPFIND', $root, '<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');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ [$folders, $files] = $this->initTestStorage($john);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PROPFIND', "{$root}/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', "{$root}/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("/{$root}/folder1/", $responses[0]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame("/{$root}/folder1/folder2/", $responses[1]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame("/{$root}/folder1/test3.txt", $responses[2]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame("/{$root}/folder1/test4.txt", $responses[3]->getElementsByTagName('href')->item(0)->textContent);
+ }
+
+ /**
+ * Test basic PUT requests
+ */
+ public function testPut(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PUT', "{$root}/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', "{$root}/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'));
+ $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', "{$root}/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'));
+ $this->assertSame('application/x-empty', $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', "{$root}/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'));
+ $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
+ $response = $this->davRequest('PUT', "{$root}/folder1/test.txt", '<html>aaa</html>', $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('16', $files[0]->getProperty('size'));
+ $this->assertSame('text/html', $files[0]->getProperty('mimetype'));
+ $this->assertSame('<html>aaa</html>', $this->getTestFileContent($files[0]));
+
+ // Test updating a file in custom folder
+ $response = $this->davRequest('PUT', "{$root}/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'));
+ $this->assertSame('text/plain', $files[0]->getProperty('mimetype'));
+ $this->assertSame('Test', $this->getTestFileContent($files[0]));
+ }
+
+ /**
+ * Test basic PROPPATCH requests
+ */
+ public function testProppatch(): void
+ {
+ $xml = <<<'EOF'
+ <d:propertyupdate xmlns:d="DAV:" xmlns:o="urn:schemas-microsoft-com:office:office">
+ <d:set>
+ <d:prop>
+ <o:Author>John Doe</o:Author>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>
+ EOF;
+
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ // Test with no Authorization header
+ $response = $this->davRequest('PROPPATCH', $root, $xml);
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test a PROPPATCH on the root
+ $response = $this->davRequest('PROPPATCH', $root, $xml, $john);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(1, $doc->getElementsByTagName('response'));
+ $this->assertSame('HTTP/1.1 403 Forbidden', $doc->getElementsByTagName('status')->item(0)->textContent);
+
+ // Note: We don't support any properties in PROPPATCH yet
+ }
+
+ /**
+ * Test basic REPORT requests
+ */
+ public function testReport(): void
+ {
+ $xml = <<<'EOF'
+ <D:version-tree xmlns:D="DAV:">
+ <D:prop>
+ <D:version-name/>
+ <D:creator-displayname/>
+ <D:successor-set/>
+ </D:prop>
+ </D:version-tree>
+ EOF;
+
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+
+ // Test with no Authorization header
+ $response = $this->davRequest('REPORT', $root, $xml);
+ $response->assertStatus(401);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+
+ // Test a REPORT on the root, this report is not supported
+ $response = $this->davRequest('REPORT', $root, $xml, $john);
+ $response->assertStatus(415);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('error', $doc->documentElement->localName);
+ }
+
+ /**
+ * Test basic UNLOCK requests (RFC 4918)
+ */
+ public function testUnlock(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $root = trim(\config('services.dav.webdav_root'), '/') . '/user/john@kolab.org';
+ $file = $this->getTestFile($john, 'test1.txt', 'Test content1', ['mimetype' => 'text/plain']);
+
+ // Test with no Authorization header
+ $response = $this->davRequest('UNLOCK', "{$root}/test1.txt");
+ $response->assertNoContent(401);
+
+ // Test unlocking a file that is not locked
+ $response = $this->davRequest('UNLOCK', "{$root}/test1.txt", '', $john, ['Lock-Token' => "<opaquelocktoken:test>"]);
+ $response->assertStatus(409);
+ // TODO: Assert XML response
+
+ $lock = $file->locks()->create([
+ 'token' => 'testtoken',
+ 'depth' => 0,
+ 'scope' => Lock::SCOPE_SHARED,
+ 'timeout' => 1800,
+ 'owner' => 'test',
+ ]);
+
+ // Test unlocking a locked file (no Lock-Token header)
+ $response = $this->davRequest('UNLOCK', "{$root}/test1.txt", '', $john);
+ $response->assertNoContent(400);
+
+ // Test unlocking a locked file
+ $response = $this->davRequest('UNLOCK', "{$root}/test1.txt", '', $john, ['Lock-Token' => "<opaquelocktoken:{$lock->token}>"]);
+ $response->assertNoContent(204);
+
+ $this->assertCount(0, $file->locks()->get());
+ }
+
+ /**
+ * 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" ?>';
+ }
+
+ // Remove space between tags for easier output handling
+ $body = preg_replace('/>[\s\t\r\n]+</', '><', $body);
+
+ $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
Fri, Apr 3, 6:37 PM (7 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18825631
Default Alt Text
D5721.1775241450.diff (112 KB)

Event Timeline