250 lines
9.6 KiB
Python
250 lines
9.6 KiB
Python
"""
|
||
WebDAV клиент для работы с Nextcloud
|
||
Сканирование директорий, загрузка файлов, получение метаданных
|
||
"""
|
||
import os
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import List, Dict, Optional, Tuple
|
||
from pathlib import Path
|
||
import requests
|
||
from requests.auth import HTTPBasicAuth
|
||
from requests.exceptions import RequestException, Timeout
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class NextcloudClient:
|
||
"""Клиент для работы с Nextcloud через WebDAV API"""
|
||
|
||
def __init__(self, url: str, username: str, password: str):
|
||
"""
|
||
Инициализация WebDAV клиента
|
||
|
||
Args:
|
||
url: URL Nextcloud (например, https://next.iieasy.ru)
|
||
username: Имя пользователя Nextcloud
|
||
password: App Password (не основной пароль!)
|
||
"""
|
||
self.base_url = url.rstrip("/")
|
||
self.webdav_url = f"{self.base_url}/remote.php/dav/files/{username}"
|
||
self.auth = HTTPBasicAuth(username, password)
|
||
self.session = requests.Session()
|
||
self.session.auth = self.auth
|
||
self.session.headers.update({
|
||
"User-Agent": "iiEasy-Nextcloud-Sync/1.0"
|
||
})
|
||
|
||
def list_directory(self, path: str, depth: int = 1) -> List[Dict]:
|
||
"""
|
||
Получение списка файлов и директорий через PROPFIND
|
||
|
||
Args:
|
||
path: Путь к директории (относительно WebDAV root)
|
||
depth: Глубина рекурсии (0 - только указанный ресурс, 1 - + дочерние)
|
||
|
||
Returns:
|
||
Список словарей с информацией о файлах/директориях
|
||
|
||
Raises:
|
||
RequestException: При ошибках сетевого запроса
|
||
"""
|
||
url = f"{self.webdav_url}/{path.lstrip('/')}"
|
||
|
||
try:
|
||
# PROPFIND запрос для получения списка ресурсов
|
||
headers = {
|
||
"Depth": str(depth),
|
||
"Content-Type": "application/xml"
|
||
}
|
||
|
||
body = """<?xml version="1.0"?>
|
||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||
<d:prop>
|
||
<d:getlastmodified/>
|
||
<d:getcontentlength/>
|
||
<d:getcontenttype/>
|
||
<oc:fileid/>
|
||
<nc:has-preview/>
|
||
</d:prop>
|
||
</d:propfind>"""
|
||
|
||
response = self.session.request(
|
||
"PROPFIND",
|
||
url,
|
||
headers=headers,
|
||
data=body,
|
||
timeout=30
|
||
)
|
||
response.raise_for_status()
|
||
|
||
# Парсинг XML ответа (упрощенная версия)
|
||
# В production лучше использовать xml.etree.ElementTree
|
||
files = []
|
||
# Здесь должен быть парсинг XML, но для простоты используем альтернативный метод
|
||
return self._parse_propfind_response(response.text)
|
||
|
||
except Timeout:
|
||
logger.error(f"Таймаут при получении списка файлов: {path}")
|
||
raise
|
||
except RequestException as e:
|
||
logger.error(f"Ошибка при получении списка файлов {path}: {e}")
|
||
raise
|
||
|
||
def _parse_propfind_response(self, xml_content: str) -> List[Dict]:
|
||
"""
|
||
Упрощенный парсинг PROPFIND ответа
|
||
В production лучше использовать xml.etree.ElementTree или lxml
|
||
"""
|
||
import re
|
||
files = []
|
||
|
||
# Простой regex парсинг (для production использовать XML парсер)
|
||
# Ищем href и getlastmodified
|
||
href_pattern = r'<d:href>(.*?)</d:href>'
|
||
modified_pattern = r'<d:getlastmodified>(.*?)</d:getlastmodified>'
|
||
size_pattern = r'<d:getcontentlength>(.*?)</d:getcontentlength>'
|
||
type_pattern = r'<d:getcontenttype>(.*?)</d:getcontenttype>'
|
||
|
||
hrefs = re.findall(href_pattern, xml_content)
|
||
modifieds = re.findall(modified_pattern, xml_content)
|
||
sizes = re.findall(size_pattern, xml_content)
|
||
types = re.findall(type_pattern, xml_content)
|
||
|
||
for i, href in enumerate(hrefs):
|
||
# Убираем префикс /remote.php/dav/files/username
|
||
clean_href = href.replace(f"/remote.php/dav/files/{self.auth.username}", "")
|
||
if clean_href == "":
|
||
continue
|
||
|
||
files.append({
|
||
"path": clean_href,
|
||
"modified": modifieds[i] if i < len(modifieds) else None,
|
||
"size": int(sizes[i]) if i < len(sizes) and sizes[i] else 0,
|
||
"type": types[i] if i < len(types) else "application/octet-stream",
|
||
"is_directory": i < len(types) and types[i] == "httpd/unix-directory"
|
||
})
|
||
|
||
return files
|
||
|
||
def download_file(self, path: str) -> bytes:
|
||
"""
|
||
Загрузка файла из Nextcloud
|
||
|
||
Args:
|
||
path: Путь к файлу (относительно WebDAV root)
|
||
|
||
Returns:
|
||
Содержимое файла в виде bytes
|
||
|
||
Raises:
|
||
RequestException: При ошибках загрузки
|
||
"""
|
||
url = f"{self.webdav_url}/{path.lstrip('/')}"
|
||
|
||
try:
|
||
response = self.session.get(url, timeout=300, stream=True)
|
||
response.raise_for_status()
|
||
return response.content
|
||
|
||
except Timeout:
|
||
logger.error(f"Таймаут при загрузке файла: {path}")
|
||
raise
|
||
except RequestException as e:
|
||
logger.error(f"Ошибка при загрузке файла {path}: {e}")
|
||
raise
|
||
|
||
def get_file_metadata(self, path: str) -> Dict:
|
||
"""
|
||
Получение метаданных файла (размер, дата изменения)
|
||
|
||
Args:
|
||
path: Путь к файлу
|
||
|
||
Returns:
|
||
Словарь с метаданными
|
||
"""
|
||
try:
|
||
url = f"{self.webdav_url}/{path.lstrip('/')}"
|
||
response = self.session.head(url, timeout=30)
|
||
response.raise_for_status()
|
||
|
||
return {
|
||
"size": int(response.headers.get("Content-Length", 0)),
|
||
"modified": response.headers.get("Last-Modified"),
|
||
"etag": response.headers.get("ETag", "").strip('"')
|
||
}
|
||
except RequestException as e:
|
||
logger.warning(f"Не удалось получить метаданные для {path}: {e}")
|
||
return {
|
||
"size": 0,
|
||
"modified": None,
|
||
"etag": ""
|
||
}
|
||
|
||
def scan_directory_recursive(self, base_path: str, max_depth: int = 10) -> List[Dict]:
|
||
"""
|
||
Рекурсивное сканирование директории
|
||
|
||
Args:
|
||
base_path: Базовый путь для сканирования
|
||
max_depth: Максимальная глубина рекурсии
|
||
|
||
Returns:
|
||
Список всех файлов с их метаданными
|
||
"""
|
||
all_files = []
|
||
|
||
def scan_recursive(current_path: str, depth: int = 0):
|
||
if depth > max_depth:
|
||
return
|
||
|
||
try:
|
||
items = self.list_directory(current_path, depth=1)
|
||
|
||
for item in items:
|
||
item_path = item["path"]
|
||
|
||
# Пропускаем сам каталог
|
||
if item_path == current_path:
|
||
continue
|
||
|
||
if item.get("is_directory"):
|
||
# Рекурсивный обход поддиректорий
|
||
scan_recursive(item_path, depth + 1)
|
||
else:
|
||
# Добавляем файл в список
|
||
all_files.append({
|
||
"path": item_path,
|
||
"size": item.get("size", 0),
|
||
"modified": item.get("modified"),
|
||
"type": item.get("type", "application/octet-stream")
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при сканировании {current_path}: {e}")
|
||
|
||
scan_recursive(base_path)
|
||
return all_files
|
||
|
||
def extract_username_from_path(self, path: str) -> Optional[str]:
|
||
"""
|
||
Извлечение имени пользователя из пути Nextcloud
|
||
Например: /home/username/Documents -> username
|
||
|
||
Args:
|
||
path: Путь к файлу/директории
|
||
|
||
Returns:
|
||
Имя пользователя или None
|
||
"""
|
||
# Формат путей: /home/{username}/...
|
||
parts = path.strip("/").split("/")
|
||
if len(parts) >= 2 and parts[0] == "home":
|
||
return parts[1]
|
||
return None
|
||
|
||
def close(self):
|
||
"""Закрытие сессии"""
|
||
self.session.close()
|