mirror of
https://github.com/bahmcloud/owncloud-backup-ha.git
synced 2026-04-06 21:41:14 +00:00
Compare commits
5 Commits
v0.1.0-alp
...
v0.1.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 4171159264 | |||
| a67a631c99 | |||
| 99c362b6d4 | |||
| 76715585ab | |||
| 15e6ae9ab7 |
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
|
||||
|
||||
## [0.1.1-alpha] - 2026-01-14
|
||||
### Fixed
|
||||
- Improved upload reliability by spooling backup streams to a temporary file and uploading with Content-Length (avoids chunked WebDAV uploads that may cause reverse proxy 504 timeouts).
|
||||
- Set a non-restrictive client timeout for WebDAV PUT requests to prevent client-side premature aborts on slow connections.
|
||||
|
||||
## [0.1.0-alpha] - 2026-01-14
|
||||
### Added
|
||||
- Initial alpha release
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
@@ -20,6 +22,7 @@ from .const import (
|
||||
DATA_CLIENT,
|
||||
DOMAIN,
|
||||
META_SUFFIX,
|
||||
SPOOL_FLUSH_BYTES,
|
||||
TAR_PREFIX,
|
||||
TAR_SUFFIX,
|
||||
)
|
||||
@@ -44,6 +47,50 @@ def _agentbackup_from_dict(d: dict[str, Any]) -> AgentBackup:
|
||||
return AgentBackup(**d) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def _spool_stream_to_tempfile(stream: AsyncIterator[bytes]) -> tuple[str, int]:
|
||||
"""Spool an async byte stream into a temporary file and return (path, size).
|
||||
|
||||
This avoids chunked WebDAV uploads and improves compatibility with reverse proxies.
|
||||
"""
|
||||
fd, path = tempfile.mkstemp(prefix="owncloud_backup_", suffix=".tar")
|
||||
os.close(fd)
|
||||
|
||||
size = 0
|
||||
buf = bytearray()
|
||||
|
||||
try:
|
||||
async for chunk in stream:
|
||||
if not chunk:
|
||||
continue
|
||||
buf.extend(chunk)
|
||||
size += len(chunk)
|
||||
|
||||
if len(buf) >= SPOOL_FLUSH_BYTES:
|
||||
data = bytes(buf)
|
||||
buf.clear()
|
||||
await asyncio.to_thread(_write_bytes_to_file, path, data, append=True)
|
||||
|
||||
if buf:
|
||||
await asyncio.to_thread(_write_bytes_to_file, path, bytes(buf), append=True)
|
||||
|
||||
return path, size
|
||||
|
||||
except Exception:
|
||||
# Ensure no leftovers on failure
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _write_bytes_to_file(path: str, data: bytes, *, append: bool) -> None:
|
||||
mode = "ab" if append else "wb"
|
||||
with open(path, mode) as f:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
|
||||
|
||||
class OwnCloudBackupAgent(BackupAgent):
|
||||
"""Backup agent storing backups in ownCloud via WebDAV."""
|
||||
|
||||
@@ -61,21 +108,35 @@ class OwnCloudBackupAgent(BackupAgent):
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup + metadata sidecar."""
|
||||
"""Upload a backup + metadata sidecar.
|
||||
|
||||
To avoid chunked uploads (which often break behind proxies), we spool
|
||||
the stream to a temp file and upload with a Content-Length.
|
||||
"""
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
tar_name = _make_tar_name(backup.backup_id)
|
||||
meta_name = _make_meta_name(backup.backup_id)
|
||||
|
||||
# 1) Upload tar stream
|
||||
# 1) Spool tar stream to temp file
|
||||
stream = await open_stream()
|
||||
await self._client.put_stream(tar_name, stream)
|
||||
temp_path, size = await _spool_stream_to_tempfile(stream)
|
||||
|
||||
# 2) Upload metadata JSON (small)
|
||||
# 2) Upload tar file with Content-Length
|
||||
await self._client.put_file(tar_name, temp_path, size)
|
||||
|
||||
# 3) Upload metadata JSON (small)
|
||||
meta_bytes = json.dumps(backup.to_dict(), ensure_ascii=False).encode("utf-8")
|
||||
await self._client.put_bytes(meta_name, meta_bytes)
|
||||
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise BackupAgentError(f"Upload to ownCloud failed: {err}") from err
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups by reading metadata sidecars; fallback to tar stat if missing."""
|
||||
@@ -217,3 +278,4 @@ def async_register_backup_agents_listener(
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
@@ -14,3 +14,6 @@ DATA_BACKUP_AGENT_LISTENERS = "backup_agent_listeners"
|
||||
TAR_PREFIX = "ha_backup_"
|
||||
TAR_SUFFIX = ".tar"
|
||||
META_SUFFIX = ".json"
|
||||
|
||||
# Spooling to temp file to avoid chunked WebDAV uploads
|
||||
SPOOL_FLUSH_BYTES = 1024 * 1024 # 1 MiB
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"domain": "owncloud_backup",
|
||||
"name": "ownCloud Backup (WebDAV)",
|
||||
"version": "0.1.0-alpha",
|
||||
"documentation": "https://github.com/your-org/owncloud-backup-ha",
|
||||
"issue_tracker": "https://github.com/your-org/owncloud-backup-ha/issues",
|
||||
"codeowners": [],
|
||||
"version": "0.1.1-alpha",
|
||||
"documentation": "https://github.com/bahmcloud/owncloud-backup-ha/",
|
||||
"issue_tracker": "https://github.com/bahmcloud/owncloud-backup-ha/",
|
||||
"codeowners": [BAHMCLOUD],
|
||||
"config_flow": true,
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"requirements": []
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
|
||||
),
|
||||
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'<d:propfind xmlns:d="DAV:"><d:prop><d:displayname/></d:prop></d:propfind>'
|
||||
),
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user