diff --git a/custom_components/owncloud_backup/webdav_client.py b/custom_components/owncloud_backup/webdav_client.py index ef0eb81..ae1299e 100644 --- a/custom_components/owncloud_backup/webdav_client.py +++ b/custom_components/owncloud_backup/webdav_client.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 import logging +import os import xml.etree.ElementTree as ET from collections.abc import AsyncIterator from datetime import datetime, timezone @@ -9,6 +10,7 @@ from email.utils import parsedate_to_datetime from typing import Final from urllib.parse import quote, urljoin +import aiohttp from aiohttp import ClientResponseError, ClientSession from yarl import URL @@ -46,6 +48,11 @@ class WebDavClient: ] self._cached_root: str | None = None + # Non-restrictive client timeouts for potentially long WebDAV operations + self._timeout_long = aiohttp.ClientTimeout( + total=None, connect=60, sock_connect=60, sock_read=None + ) + def _auth_header(self) -> str: token = base64.b64encode(f"{self._username}:{self._password}".encode("utf-8")).decode("ascii") return f"Basic {token}" @@ -82,6 +89,7 @@ class WebDavClient: b'' ), raise_for_status=True, + timeout=self._timeout_long, ): self._cached_root = root return root @@ -106,6 +114,7 @@ class WebDavClient: base_folder, headers=self._headers({"Depth": "0"}), raise_for_status=True, + timeout=self._timeout_long, ): return except ClientResponseError as err: @@ -127,7 +136,11 @@ class WebDavClient: # exists? try: async with self._session.request( - "PROPFIND", url, headers=self._headers({"Depth": "0"}), raise_for_status=True + "PROPFIND", + url, + headers=self._headers({"Depth": "0"}), + raise_for_status=True, + timeout=self._timeout_long, ): return except ClientResponseError as err: @@ -135,7 +148,9 @@ class WebDavClient: raise # create - async with self._session.request("MKCOL", url, headers=self._headers()) as resp: + async with self._session.request( + "MKCOL", url, headers=self._headers(), timeout=self._timeout_long + ) as resp: if resp.status in (201, 405): return text = await resp.text() @@ -154,6 +169,7 @@ class WebDavClient: b'' ), raise_for_status=True, + timeout=self._timeout_long, ) as resp: body = await resp.text() @@ -195,10 +211,42 @@ class WebDavClient: async def put_bytes(self, name: str, data: bytes) -> None: folder = await self._base_folder_url() url = self._file_url(folder, name) - async with self._session.put(url, data=data, headers=self._headers(), raise_for_status=True): + async with self._session.put( + url, + data=data, + headers=self._headers({"Content-Length": str(len(data))}), + raise_for_status=True, + timeout=self._timeout_long, + ): return + async def put_file(self, name: str, path: str, size: int) -> None: + """Upload a local file with an explicit Content-Length (non-chunked).""" + folder = await self._base_folder_url() + url = self._file_url(folder, name) + + # Ensure correct size if caller passes 0/unknown + if size <= 0: + try: + size = os.path.getsize(path) + except OSError: + size = 0 + + headers = {"Content-Length": str(size)} if size > 0 else {} + + # aiohttp will stream file content; with Content-Length set, proxies are usually happier. + with open(path, "rb") as f: + async with self._session.put( + url, + data=f, + headers=self._headers(headers), + raise_for_status=True, + timeout=self._timeout_long, + ): + return + async def put_stream(self, name: str, stream: AsyncIterator[bytes]) -> None: + """Legacy method: chunked upload. Prefer put_file for better proxy compatibility.""" folder = await self._base_folder_url() url = self._file_url(folder, name) @@ -206,13 +254,19 @@ class WebDavClient: async for chunk in stream: yield chunk - async with self._session.put(url, data=gen(), headers=self._headers(), raise_for_status=True): + async with self._session.put( + url, + data=gen(), + headers=self._headers(), + raise_for_status=True, + timeout=self._timeout_long, + ): return async def get_bytes(self, name: str) -> bytes: folder = await self._base_folder_url() url = self._file_url(folder, name) - async with self._session.get(url, headers=self._headers()) as resp: + async with self._session.get(url, headers=self._headers(), timeout=self._timeout_long) as resp: if resp.status == 404: raise FileNotFoundError(name) resp.raise_for_status() @@ -221,7 +275,7 @@ class WebDavClient: async def get_stream(self, name: str) -> AsyncIterator[bytes]: folder = await self._base_folder_url() url = self._file_url(folder, name) - resp = await self._session.get(url, headers=self._headers()) + resp = await self._session.get(url, headers=self._headers(), timeout=self._timeout_long) if resp.status == 404: await resp.release() raise FileNotFoundError(name) @@ -239,7 +293,7 @@ class WebDavClient: async def delete(self, name: str) -> None: folder = await self._base_folder_url() url = self._file_url(folder, name) - async with self._session.delete(url, headers=self._headers()) as resp: + async with self._session.delete(url, headers=self._headers(), timeout=self._timeout_long) as resp: if resp.status == 404: raise FileNotFoundError(name) if resp.status in (200, 202, 204): @@ -267,6 +321,7 @@ class WebDavClient: url, headers=self._headers({"Depth": "0", "Content-Type": "application/xml; charset=utf-8"}), data=body, + timeout=self._timeout_long, ) as resp: if resp.status == 404: raise FileNotFoundError(name)