Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117774817
D5721.1775241450.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
112 KB
Referenced Files
None
Subscribers
None
D5721.1775241450.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D5721: WebDAV Server
Attached
Detach File
Event Timeline