463 lines
15 KiB
PHP
463 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace League\Flysystem\WebDAV;
|
|
|
|
use League\Flysystem\Config;
|
|
use League\Flysystem\DirectoryAttributes;
|
|
use League\Flysystem\FileAttributes;
|
|
use League\Flysystem\FilesystemAdapter;
|
|
use League\Flysystem\PathPrefixer;
|
|
use League\Flysystem\UnableToCheckDirectoryExistence;
|
|
use League\Flysystem\UnableToCheckFileExistence;
|
|
use League\Flysystem\UnableToCopyFile;
|
|
use League\Flysystem\UnableToCreateDirectory;
|
|
use League\Flysystem\UnableToDeleteDirectory;
|
|
use League\Flysystem\UnableToDeleteFile;
|
|
use League\Flysystem\UnableToMoveFile;
|
|
use League\Flysystem\UnableToReadFile;
|
|
use League\Flysystem\UnableToRetrieveMetadata;
|
|
use League\Flysystem\UnableToSetVisibility;
|
|
use League\Flysystem\UnableToWriteFile;
|
|
use League\Flysystem\UrlGeneration\PublicUrlGenerator;
|
|
use RuntimeException;
|
|
use Sabre\DAV\Client;
|
|
use Sabre\DAV\Xml\Property\ResourceType;
|
|
use Sabre\HTTP\ClientHttpException;
|
|
use Sabre\HTTP\Request;
|
|
use Throwable;
|
|
|
|
use function array_key_exists;
|
|
use function array_shift;
|
|
use function dirname;
|
|
use function explode;
|
|
use function fclose;
|
|
use function implode;
|
|
use function parse_url;
|
|
use function rawurldecode;
|
|
|
|
class WebDAVAdapter implements FilesystemAdapter, PublicUrlGenerator
|
|
{
|
|
public const ON_VISIBILITY_THROW_ERROR = 'throw';
|
|
public const ON_VISIBILITY_IGNORE = 'ignore';
|
|
public const FIND_PROPERTIES = [
|
|
'{DAV:}displayname',
|
|
'{DAV:}getcontentlength',
|
|
'{DAV:}getcontenttype',
|
|
'{DAV:}getlastmodified',
|
|
'{DAV:}iscollection',
|
|
'{DAV:}resourcetype',
|
|
];
|
|
|
|
private PathPrefixer $prefixer;
|
|
|
|
public function __construct(
|
|
private Client $client,
|
|
string $prefix = '',
|
|
private string $visibilityHandling = self::ON_VISIBILITY_THROW_ERROR,
|
|
private bool $manualCopy = false,
|
|
private bool $manualMove = false,
|
|
) {
|
|
$this->prefixer = new PathPrefixer($prefix);
|
|
}
|
|
|
|
public function fileExists(string $path): bool
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']);
|
|
|
|
return ! $this->propsIsDirectory($properties);
|
|
} catch (Throwable $exception) {
|
|
if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) {
|
|
return false;
|
|
}
|
|
|
|
throw UnableToCheckFileExistence::forLocation($path, $exception);
|
|
}
|
|
}
|
|
|
|
protected function encodePath(string $path): string
|
|
{
|
|
$parts = explode('/', $path);
|
|
|
|
foreach ($parts as $i => $part) {
|
|
$parts[$i] = rawurlencode($part);
|
|
}
|
|
|
|
return implode('/', $parts);
|
|
}
|
|
|
|
public function directoryExists(string $path): bool
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']);
|
|
|
|
return $this->propsIsDirectory($properties);
|
|
} catch (Throwable $exception) {
|
|
if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) {
|
|
return false;
|
|
}
|
|
|
|
throw UnableToCheckDirectoryExistence::forLocation($path, $exception);
|
|
}
|
|
}
|
|
|
|
public function write(string $path, string $contents, Config $config): void
|
|
{
|
|
$this->upload($path, $contents);
|
|
}
|
|
|
|
public function writeStream(string $path, $contents, Config $config): void
|
|
{
|
|
$this->upload($path, $contents);
|
|
}
|
|
|
|
/**
|
|
* @param resource|string $contents
|
|
*/
|
|
private function upload(string $path, mixed $contents): void
|
|
{
|
|
$this->createParentDirFor($path);
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$response = $this->client->request('PUT', $location, $contents);
|
|
$statusCode = $response['statusCode'];
|
|
|
|
if ($statusCode < 200 || $statusCode >= 300) {
|
|
throw new RuntimeException('Unexpected status code received: ' . $statusCode);
|
|
}
|
|
} catch (Throwable $exception) {
|
|
throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function read(string $path): string
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$response = $this->client->request('GET', $location);
|
|
|
|
if ($response['statusCode'] !== 200) {
|
|
throw new RuntimeException('Unexpected response code for GET: ' . $response['statusCode']);
|
|
}
|
|
|
|
return $response['body'];
|
|
} catch (Throwable $exception) {
|
|
throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function readStream(string $path)
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$url = $this->client->getAbsoluteUrl($location);
|
|
$request = new Request('GET', $url);
|
|
$response = $this->client->send($request);
|
|
$status = $response->getStatus();
|
|
|
|
if ($status !== 200) {
|
|
throw new RuntimeException('Unexpected response code for GET: ' . $status);
|
|
}
|
|
|
|
return $response->getBodyAsStream();
|
|
} catch (Throwable $exception) {
|
|
throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function delete(string $path): void
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$response = $this->client->request('DELETE', $location);
|
|
$statusCode = $response['statusCode'];
|
|
|
|
if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) {
|
|
throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode);
|
|
}
|
|
} catch (Throwable $exception) {
|
|
if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) {
|
|
throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function deleteDirectory(string $path): void
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixDirectoryPath($path));
|
|
|
|
try {
|
|
$statusCode = $this->client->request('DELETE', $location)['statusCode'];
|
|
|
|
if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) {
|
|
throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode);
|
|
}
|
|
} catch (Throwable $exception) {
|
|
if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) {
|
|
throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function createDirectory(string $path, Config $config): void
|
|
{
|
|
$parts = explode('/', $this->prefixer->prefixDirectoryPath($path));
|
|
$directoryParts = [];
|
|
|
|
foreach ($parts as $directory) {
|
|
if ($directory === '.' || $directory === '') {
|
|
return;
|
|
}
|
|
|
|
$directoryParts[] = $directory;
|
|
$directoryPath = implode('/', $directoryParts);
|
|
$location = $this->encodePath($directoryPath) . '/';
|
|
|
|
if ($this->directoryExists($this->prefixer->stripDirectoryPrefix($directoryPath))) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$response = $this->client->request('MKCOL', $location);
|
|
} catch (Throwable $exception) {
|
|
throw UnableToCreateDirectory::dueToFailure($path, $exception);
|
|
}
|
|
|
|
if ($response['statusCode'] === 405) {
|
|
continue;
|
|
}
|
|
|
|
if ($response['statusCode'] !== 201) {
|
|
throw UnableToCreateDirectory::atLocation($path, 'Failed to create directory at: ' . $location);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function setVisibility(string $path, string $visibility): void
|
|
{
|
|
if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) {
|
|
throw UnableToSetVisibility::atLocation($path, 'WebDAV does not support this operation.');
|
|
}
|
|
}
|
|
|
|
public function visibility(string $path): FileAttributes
|
|
{
|
|
throw UnableToRetrieveMetadata::visibility($path, 'WebDAV does not support this operation.');
|
|
}
|
|
|
|
public function mimeType(string $path): FileAttributes
|
|
{
|
|
$mimeType = (string) $this->propFind($path, 'mime_type', '{DAV:}getcontenttype');
|
|
|
|
return new FileAttributes($path, mimeType: $mimeType);
|
|
}
|
|
|
|
public function lastModified(string $path): FileAttributes
|
|
{
|
|
$lastModified = $this->propFind($path, 'last_modified', '{DAV:}getlastmodified');
|
|
|
|
return new FileAttributes($path, lastModified: strtotime($lastModified));
|
|
}
|
|
|
|
public function fileSize(string $path): FileAttributes
|
|
{
|
|
$fileSize = (int) $this->propFind($path, 'file_size', '{DAV:}getcontentlength');
|
|
|
|
return new FileAttributes($path, fileSize: $fileSize);
|
|
}
|
|
|
|
public function listContents(string $path, bool $deep): iterable
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixDirectoryPath($path));
|
|
$response = $this->client->propFind($location, self::FIND_PROPERTIES, 1);
|
|
|
|
// This is the directory itself, the files are subsequent entries.
|
|
array_shift($response);
|
|
|
|
foreach ($response as $path => $object) {
|
|
$path = (string) parse_url(rawurldecode($path), PHP_URL_PATH);
|
|
$path = $this->prefixer->stripPrefix($path);
|
|
$object = $this->normalizeObject($object);
|
|
|
|
if ($this->propsIsDirectory($object)) {
|
|
yield new DirectoryAttributes($path, lastModified: $object['last_modified'] ?? null);
|
|
|
|
if ( ! $deep) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($this->listContents($path, true) as $child) {
|
|
yield $child;
|
|
}
|
|
} else {
|
|
yield new FileAttributes(
|
|
$path,
|
|
fileSize: $object['file_size'] ?? null,
|
|
lastModified: $object['last_modified'] ?? null,
|
|
mimeType: $object['mime_type'] ?? null,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function normalizeObject(array $object): array
|
|
{
|
|
$mapping = [
|
|
'{DAV:}getcontentlength' => 'file_size',
|
|
'{DAV:}getcontenttype' => 'mime_type',
|
|
'content-length' => 'file_size',
|
|
'content-type' => 'mime_type',
|
|
];
|
|
|
|
foreach ($mapping as $from => $to) {
|
|
if (array_key_exists($from, $object)) {
|
|
$object[$to] = $object[$from];
|
|
}
|
|
}
|
|
|
|
array_key_exists('file_size', $object) && $object['file_size'] = (int) $object['file_size'];
|
|
|
|
if (array_key_exists('{DAV:}getlastmodified', $object)) {
|
|
$object['last_modified'] = strtotime($object['{DAV:}getlastmodified']);
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
public function move(string $source, string $destination, Config $config): void
|
|
{
|
|
if ($source === $destination) {
|
|
return;
|
|
}
|
|
|
|
if ($this->manualMove) {
|
|
$this->manualMove($source, $destination);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->createParentDirFor($destination);
|
|
$location = $this->encodePath($this->prefixer->prefixPath($source));
|
|
$newLocation = $this->encodePath($this->prefixer->prefixPath($destination));
|
|
|
|
try {
|
|
$response = $this->client->request('MOVE', $location, null, [
|
|
'Destination' => $this->client->getAbsoluteUrl($newLocation),
|
|
]);
|
|
|
|
if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) {
|
|
throw new RuntimeException('MOVE command returned unexpected status code: ' . $response['statusCode'] . "\n{$response['body']}");
|
|
}
|
|
} catch (Throwable $e) {
|
|
throw UnableToMoveFile::fromLocationTo($source, $destination, $e);
|
|
}
|
|
}
|
|
|
|
private function manualMove(string $source, string $destination): void
|
|
{
|
|
try {
|
|
$handle = $this->readStream($source);
|
|
$this->writeStream($destination, $handle, new Config());
|
|
@fclose($handle);
|
|
$this->delete($source);
|
|
} catch (Throwable $exception) {
|
|
throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
|
|
}
|
|
}
|
|
|
|
public function copy(string $source, string $destination, Config $config): void
|
|
{
|
|
if ($source === $destination) {
|
|
return;
|
|
}
|
|
|
|
if ($this->manualCopy) {
|
|
$this->manualCopy($source, $destination);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->createParentDirFor($destination);
|
|
$location = $this->encodePath($this->prefixer->prefixPath($source));
|
|
$newLocation = $this->encodePath($this->prefixer->prefixPath($destination));
|
|
|
|
try {
|
|
$response = $this->client->request('COPY', $location, null, [
|
|
'Destination' => $this->client->getAbsoluteUrl($newLocation),
|
|
]);
|
|
|
|
if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) {
|
|
throw new RuntimeException('COPY command returned unexpected status code: ' . $response['statusCode']);
|
|
}
|
|
} catch (Throwable $e) {
|
|
throw UnableToCopyFile::fromLocationTo($source, $destination, $e);
|
|
}
|
|
}
|
|
|
|
private function manualCopy(string $source, string $destination): void
|
|
{
|
|
try {
|
|
$handle = $this->readStream($source);
|
|
$this->writeStream($destination, $handle, new Config());
|
|
@fclose($handle);
|
|
} catch (Throwable $exception) {
|
|
throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
|
|
}
|
|
}
|
|
|
|
private function propsIsDirectory(array $properties): bool
|
|
{
|
|
if (isset($properties['{DAV:}resourcetype'])) {
|
|
/** @var ResourceType $resourceType */
|
|
$resourceType = $properties['{DAV:}resourcetype'];
|
|
|
|
return $resourceType->is('{DAV:}collection');
|
|
}
|
|
|
|
return isset($properties['{DAV:}iscollection']) && $properties['{DAV:}iscollection'] === '1';
|
|
}
|
|
|
|
private function createParentDirFor(string $path): void
|
|
{
|
|
$dirname = dirname($path);
|
|
|
|
if ($this->directoryExists($dirname)) {
|
|
return;
|
|
}
|
|
|
|
$this->createDirectory($dirname, new Config());
|
|
}
|
|
|
|
private function propFind(string $path, string $section, string $property): mixed
|
|
{
|
|
$location = $this->encodePath($this->prefixer->prefixPath($path));
|
|
|
|
try {
|
|
$result = $this->client->propFind($location, [$property]);
|
|
|
|
if ( ! array_key_exists($property, $result)) {
|
|
throw new RuntimeException('Invalid response, missing key: ' . $property);
|
|
}
|
|
|
|
return $result[$property];
|
|
} catch (Throwable $exception) {
|
|
throw UnableToRetrieveMetadata::create($path, $section, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function publicUrl(string $path, Config $config): string
|
|
{
|
|
return $this->client->getAbsoluteUrl($this->encodePath($this->prefixer->prefixPath($path)));
|
|
}
|
|
}
|