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))); } }