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

} 

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc