nextcloud_webdav_client-1.0.x-dev/src/Service/NextCloudWebDavClient.php
src/Service/NextCloudWebDavClient.php
<?php
namespace Drupal\nextcloud_webdav_client\Service;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;
/**
* NextCloud WebDAV client service.
*/
class NextCloudWebDavClient {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The logger channel.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $httpClient;
/**
* The module configuration.
*
* @var \Drupal\Core\Config\ImmutableConfig
*/
protected $config;
/**
* The OAuth2 manager service.
*
* @var \Drupal\nextcloud_webdav_client\Service\NextCloudOAuth2Manager
*/
protected $oauth2Manager;
/**
* Constructs a NextCloudWebDavClient object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory.
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client.
* @param \Drupal\nextcloud_webdav_client\Service\NextCloudOAuth2Manager $oauth2_manager
* The OAuth2 manager service.
*/
public function __construct(ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, ClientInterface $http_client, NextCloudOAuth2Manager $oauth2_manager) {
$this->configFactory = $config_factory;
$this->logger = $logger_factory->get('nextcloud_webdav_client');
$this->httpClient = $http_client;
$this->oauth2Manager = $oauth2_manager;
$this->config = $this->configFactory->get('nextcloud_webdav_client.settings');
}
/**
* Tests the connection to the NextCloud server.
*
* @return bool
* TRUE if connection is successful, FALSE otherwise.
*/
public function testConnection(): bool {
try {
$url = $this->buildUrl('');
$response = $this->httpClient->request('PROPFIND', $url, $this->getRequestOptions());
return $response->getStatusCode() === 207;
}
catch (RequestException $e) {
$this->logger->error('Connection test failed: @message', ['@message' => $e->getMessage()]);
return FALSE;
}
}
/**
* Creates a folder in NextCloud.
*
* @param string $path
* The folder path to create.
*
* @return bool
* TRUE if folder was created successfully, FALSE otherwise.
*/
public function createFolder(string $path): bool {
try {
$url = $this->buildUrl($path);
$response = $this->httpClient->request('MKCOL', $url, $this->getRequestOptions());
if (in_array($response->getStatusCode(), [201, 405])) {
$this->logger->info('Folder created successfully: @path', ['@path' => $path]);
return TRUE;
}
$this->logger->error('Failed to create folder: @path. Status code: @code', [
'@path' => $path,
'@code' => $response->getStatusCode(),
]);
return FALSE;
}
catch (RequestException $e) {
$this->logger->error('Error creating folder @path: @message', [
'@path' => $path,
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Uploads a file to NextCloud.
*
* @param string $local_path
* The local file path.
* @param string $remote_path
* The remote file path in NextCloud.
*
* @return bool
* TRUE if file was uploaded successfully, FALSE otherwise.
*/
public function uploadFile(string $local_path, string $remote_path): bool {
if (!file_exists($local_path)) {
$this->logger->error('Local file does not exist: @path', ['@path' => $local_path]);
return FALSE;
}
try {
$url = $this->buildUrl($remote_path);
$options = $this->getRequestOptions();
$options[RequestOptions::BODY] = fopen($local_path, 'r');
$response = $this->httpClient->request('PUT', $url, $options);
if (in_array($response->getStatusCode(), [201, 204])) {
$this->logger->info('File uploaded successfully: @local to @remote', [
'@local' => $local_path,
'@remote' => $remote_path,
]);
return TRUE;
}
$this->logger->error('Failed to upload file: @local to @remote. Status code: @code', [
'@local' => $local_path,
'@remote' => $remote_path,
'@code' => $response->getStatusCode(),
]);
return FALSE;
}
catch (RequestException $e) {
$this->logger->error('Error uploading file @local to @remote: @message', [
'@local' => $local_path,
'@remote' => $remote_path,
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Uploads file content to NextCloud.
*
* @param string $content
* The file content.
* @param string $remote_path
* The remote file path in NextCloud.
*
* @return bool
* TRUE if content was uploaded successfully, FALSE otherwise.
*/
public function uploadContent(string $content, string $remote_path): bool {
try {
$url = $this->buildUrl($remote_path);
$options = $this->getRequestOptions();
$options[RequestOptions::BODY] = $content;
$response = $this->httpClient->request('PUT', $url, $options);
if (in_array($response->getStatusCode(), [201, 204])) {
$this->logger->info('Content uploaded successfully to: @remote', ['@remote' => $remote_path]);
return TRUE;
}
$this->logger->error('Failed to upload content to: @remote. Status code: @code', [
'@remote' => $remote_path,
'@code' => $response->getStatusCode(),
]);
return FALSE;
}
catch (RequestException $e) {
$this->logger->error('Error uploading content to @remote: @message', [
'@remote' => $remote_path,
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Lists files and folders in a directory.
*
* @param string $path
* The directory path to list.
*
* @return array|false
* Array of files and folders, or FALSE on error.
*/
public function listDirectory(string $path = ''): array|false {
try {
$url = $this->buildUrl($path);
$options = $this->getRequestOptions('application/xml');
// Set proper headers for PROPFIND
$options[RequestOptions::HEADERS]['Depth'] = '1';
// Add PROPFIND XML body to request specific properties
$propfind_body = '<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:creationdate/>
</d:prop>
</d:propfind>';
$options[RequestOptions::BODY] = $propfind_body;
$response = $this->httpClient->request('PROPFIND', $url, $options);
if ($response->getStatusCode() === 207) {
$body = $response->getBody()->getContents();
$this->logger->debug('WebDAV PROPFIND response: @response', ['@response' => $body]);
return $this->parseWebDavResponse($body, $url);
}
$this->logger->error('PROPFIND request failed with status code: @code', [
'@code' => $response->getStatusCode(),
]);
return FALSE;
}
catch (RequestException $e) {
$this->logger->error('Error listing directory @path: @message', [
'@path' => $path,
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Checks if the module is properly configured.
*
* @return bool
* TRUE if configured, FALSE otherwise.
*/
public function isConfigured(): bool {
$server_url = $this->config->get('server_url');
$auth_mode = $this->config->get('auth_mode') ?: 'basic';
// Server URL is always required.
if (empty($server_url)) {
return FALSE;
}
// Check configuration based on authentication mode.
if ($auth_mode === 'oauth2') {
// For OAuth2, check if OAuth2 is configured and has valid tokens.
return $this->oauth2Manager->isConfigured() && $this->oauth2Manager->isTokenValid();
}
else {
// For Basic Auth (default), check username and password.
$username = $this->config->get('username');
$password = $this->config->get('password');
return !empty($username) && !empty($password);
}
}
/**
* Gets the raw WebDAV response for debugging.
*
* @param string $path
* The directory path to list.
*
* @return array
* Array with response details for debugging.
*/
public function debugListDirectory(string $path = ''): array {
$debug_info = [
'url' => '',
'status_code' => 0,
'response_body' => '',
'error' => '',
];
try {
$url = $this->buildUrl($path);
$debug_info['url'] = $url;
$options = $this->getRequestOptions('application/xml');
$options[RequestOptions::HEADERS]['Depth'] = '1';
$propfind_body = '<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<d:getcontentlength/>
<d:getlastmodified/>
<d:creationdate/>
</d:prop>
</d:propfind>';
$options[RequestOptions::BODY] = $propfind_body;
$response = $this->httpClient->request('PROPFIND', $url, $options);
$debug_info['status_code'] = $response->getStatusCode();
$debug_info['response_body'] = $response->getBody()->getContents();
}
catch (RequestException $e) {
$debug_info['error'] = $e->getMessage();
}
return $debug_info;
}
/**
* Validates and sanitizes a file path to prevent directory traversal attacks.
*
* @param string $path
* The path to validate.
*
* @return string
* The sanitized path.
*
* @throws \InvalidArgumentException
* If the path contains invalid characters or traversal attempts.
*/
protected function validateAndSanitizePath(string $path): string {
// Remove any null bytes.
$path = str_replace("\0", '', $path);
// Normalize path separators to forward slashes.
$path = str_replace('\\', '/', $path);
// Remove directory traversal attempts.
$path = preg_replace('#/\.\.(/|$)#', '/', $path);
$path = preg_replace('#/\.(/|$)#', '/', $path);
$path = str_replace('../', '', $path);
$path = str_replace('..\\', '', $path);
// Remove leading slashes and dots.
$path = ltrim($path, '/.\\');
// Validate that path doesn't start with forbidden patterns after sanitization.
if (preg_match('/^\.\./', $path)) {
throw new \InvalidArgumentException('Invalid path: directory traversal detected');
}
// Check for absolute paths (Linux/Mac and Windows).
if (preg_match('#^(/|[a-zA-Z]:)#', $path)) {
throw new \InvalidArgumentException('Invalid path: absolute paths not allowed');
}
// Remove multiple consecutive slashes.
$path = preg_replace('#/+#', '/', $path);
// Remove trailing slashes.
$path = rtrim($path, '/');
// Validate allowed characters (alphanumeric, dashes, underscores, dots, slashes, spaces).
if (!preg_match('/^[a-zA-Z0-9\/_\.\- ]*$/', $path)) {
$this->logger->warning('Path contains potentially unsafe characters: @path', ['@path' => $path]);
}
return $path;
}
/**
* Builds the full URL for a WebDAV request.
*
* @param string $path
* The path to append.
*
* @return string
* The full URL.
*/
protected function buildUrl(string $path): string {
$server_url = rtrim($this->config->get('server_url'), '/');
$base_path = trim($this->config->get('base_path'), '/');
$auth_mode = $this->config->get('auth_mode') ?: 'basic';
// Validate and sanitize the path to prevent directory traversal.
try {
$path = $this->validateAndSanitizePath($path);
}
catch (\InvalidArgumentException $e) {
$this->logger->error('Path validation failed: @message', ['@message' => $e->getMessage()]);
throw $e;
}
// Get username based on auth mode.
if ($auth_mode === 'oauth2') {
$oauth2_mode = $this->config->get('oauth2_mode') ?: 'site';
if ($oauth2_mode === 'per-user') {
// Get username from current user's token.
$storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
$current_user = \Drupal::currentUser();
if ($current_user->isAuthenticated()) {
$user = \Drupal\user\Entity\User::load($current_user->id());
$token_entity = $storage->loadByUser($user);
$username = $token_entity ? $token_entity->getNextCloudUsername() : '';
}
else {
$username = '';
}
}
else {
// Site-wide username.
$username = $this->config->get('oauth2_username');
}
}
else {
$username = $this->config->get('username');
}
return $server_url . '/' . $base_path . '/' . $username . '/' . $path;
}
/**
* Gets the default request options for HTTP requests.
*
* @param string $content_type
* The content type for the request.
*
* @return array
* The request options.
*/
protected function getRequestOptions(string $content_type = 'application/octet-stream'): array {
// Get authentication mode (defaults to 'basic' for backward compatibility).
$auth_mode = $this->config->get('auth_mode') ?: 'basic';
$options = [
RequestOptions::TIMEOUT => $this->config->get('timeout') ?: 30,
RequestOptions::VERIFY => $this->config->get('verify_ssl') !== FALSE,
RequestOptions::HEADERS => [
'Content-Type' => $content_type,
],
];
// Add authentication based on mode.
if ($auth_mode === 'oauth2') {
$oauth2_mode = $this->config->get('oauth2_mode') ?: 'site';
if ($oauth2_mode === 'per-user') {
// Get current user's token.
$current_user = \Drupal::currentUser();
if ($current_user->isAuthenticated()) {
$user = \Drupal\user\Entity\User::load($current_user->id());
$access_token = $this->oauth2Manager->getUserAccessToken($user);
}
else {
$access_token = NULL;
}
}
else {
// Site-wide token (existing behavior).
$access_token = $this->oauth2Manager->getAccessToken();
}
if ($access_token) {
$options[RequestOptions::HEADERS]['Authorization'] = 'Bearer ' . $access_token;
}
else {
$this->logger->warning('OAuth2 mode selected but no valid access token available.');
}
}
else {
// Default to Basic Auth (backward compatible).
$options[RequestOptions::AUTH] = [
$this->config->get('username'),
$this->config->get('password'),
];
}
return $options;
}
/**
* Parses a WebDAV PROPFIND response.
*
* @param string $xml_content
* The XML response content.
* @param string $request_url
* The original request URL to filter out the current directory.
*
* @return array
* Parsed directory listing.
*/
protected function parseWebDavResponse(string $xml_content, string $request_url = ''): array {
$items = [];
try {
// Handle XML parsing errors gracefully
libxml_use_internal_errors(true);
$xml = new \SimpleXMLElement($xml_content);
$xml->registerXPathNamespace('d', 'DAV:');
$responses = $xml->xpath('//d:response');
if (empty($responses)) {
$this->logger->warning('No responses found in WebDAV XML');
return $items;
}
foreach ($responses as $response) {
$href_elements = $response->xpath('d:href');
if (empty($href_elements)) {
continue;
}
$href = (string) $href_elements[0];
// Skip the current directory entry (it's usually the first one)
if (!empty($request_url) && rtrim($href, '/') === rtrim(parse_url($request_url, PHP_URL_PATH), '/')) {
continue;
}
$propstat_elements = $response->xpath('d:propstat');
if (empty($propstat_elements)) {
continue;
}
foreach ($propstat_elements as $propstat) {
$status_elements = $propstat->xpath('d:status');
if (empty($status_elements)) {
continue;
}
$status = (string) $status_elements[0];
// Only process successful responses (HTTP 200)
if (strpos($status, '200') === FALSE) {
continue;
}
$prop_elements = $propstat->xpath('d:prop');
if (empty($prop_elements)) {
continue;
}
$props = $prop_elements[0];
// Check if it's a collection (directory)
$resourcetype_elements = $props->xpath('d:resourcetype');
$is_collection = FALSE;
if (!empty($resourcetype_elements)) {
$collection_elements = $resourcetype_elements[0]->xpath('d:collection');
$is_collection = !empty($collection_elements);
}
// Get display name
$displayname_elements = $props->xpath('d:displayname');
$display_name = '';
if (!empty($displayname_elements)) {
$display_name = (string) $displayname_elements[0];
}
// Fallback to basename of href if no display name
if (empty($display_name)) {
$display_name = basename(rtrim($href, '/'));
}
// Skip empty names and current/parent directory references
if (empty($display_name) || $display_name === '.' || $display_name === '..') {
continue;
}
// Get additional properties
$size = '';
$modified = '';
$contentlength_elements = $props->xpath('d:getcontentlength');
if (!empty($contentlength_elements)) {
$size = (string) $contentlength_elements[0];
}
$lastmodified_elements = $props->xpath('d:getlastmodified');
if (!empty($lastmodified_elements)) {
$modified = (string) $lastmodified_elements[0];
}
$items[] = [
'name' => $display_name,
'href' => $href,
'type' => $is_collection ? 'directory' : 'file',
'size' => $size,
'modified' => $modified,
];
}
}
$this->logger->debug('Parsed @count items from WebDAV response', ['@count' => count($items)]);
}
catch (\Exception $e) {
$this->logger->error('Error parsing WebDAV response: @message', ['@message' => $e->getMessage()]);
// Log XML parsing errors if any
$xml_errors = libxml_get_errors();
foreach ($xml_errors as $error) {
$this->logger->error('XML parsing error: @error', ['@error' => $error->message]);
}
libxml_clear_errors();
}
return $items;
}
} 