feat(12-02): normalize all four providers into CloudResourceAdapter contract

- GoogleDriveBackend: list_folder with full pagination, native doc size=None, get_capabilities
- OneDriveBackend: list_folder with @odata.nextLink pagination, get_capabilities
- WebDAVBackend: list_folder via PROPFIND with SSRF re-validation, get_capabilities
- NextcloudBackend: inherits CloudResourceAdapter from WebDAVBackend
- build_cloud_resource_adapter() added to factory
- 22 new contract/behavior/pagination/no-byte-download tests (51 total pass)
This commit is contained in:
curo1305
2026-06-18 22:45:09 +02:00
parent 71ba0293a5
commit ff33439f0a
5 changed files with 844 additions and 3 deletions
+149 -1
View File
@@ -29,16 +29,31 @@ from __future__ import annotations
import asyncio
import io
import uuid
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from webdav3.client import Client
from storage.base import StorageBackend
from storage.cloud_base import (
ACTIONS,
REASON_OFFLINE,
REASON_PROVIDER_UNSUPPORTED,
STATE_SUPPORTED,
STATE_TEMPORARILY_UNAVAILABLE,
STATE_UNSUPPORTED,
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.cloud_utils import validate_cloud_url
class WebDAVBackend(StorageBackend):
class WebDAVBackend(StorageBackend, CloudResourceAdapter):
"""Generic WebDAV storage backend implementing all 7 StorageBackend abstract methods.
All synchronous webdavclient3 calls are wrapped in asyncio.to_thread() to avoid
@@ -220,3 +235,136 @@ class WebDAVBackend(StorageBackend):
return bool(result)
except Exception:
return False
# ── CloudResourceAdapter interface ────────────────────────────────────────
async def list_folder(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
parent_ref: Optional[str] = None,
page_token: Optional[str] = None,
) -> CloudListing:
"""List direct children of a WebDAV folder via non-mutating PROPFIND.
parent_ref=None lists the WebDAV root.
Uses client.list() + client.info() — no file byte download, no mutation.
SSRF guard is re-validated before every outbound request (D-17).
Absent properties treated as unknown (not unsupported).
"""
folder_path = parent_ref if parent_ref else ""
def _propfind() -> list[dict]:
validate_cloud_url(self._server_url)
items = self._client.list(folder_path)
folder_norm = folder_path.strip("/")
result: list[dict] = []
for name in items:
if not name or name in (".", "./"):
continue
if name.strip("/") == folder_norm:
continue
if folder_path:
item_path = f"{folder_path.rstrip('/')}/{name}"
else:
item_path = name
validate_cloud_url(self._server_url)
try:
info = self._client.info(item_path)
size_raw = info.get("size")
size = int(size_raw) if size_raw is not None else 0
is_dir = (
info.get("isdir", False)
or str(info.get("content_type", "")).startswith("httpd/unix-directory")
or name.endswith("/")
)
etag = info.get("etag") or info.get("getetag")
mod_str = info.get("modified") or info.get("getlastmodified")
content_type = info.get("content_type") or info.get("getcontenttype")
except Exception:
size = 0
is_dir = name.endswith("/")
etag = None
mod_str = None
content_type = None
result.append({
"path": item_path,
"name": name.rstrip("/"),
"is_dir": is_dir,
"size": size,
"etag": etag,
"modified": mod_str,
"content_type": content_type,
})
return result
try:
raw_items = await asyncio.to_thread(_propfind)
complete = True
except Exception:
return CloudListing(items=(), complete=False)
resources: list[CloudResource] = []
for item in raw_items:
kind = "folder" if item["is_dir"] else "file"
size: Optional[int] = None if item["is_dir"] else item["size"]
modified_at: Optional[datetime] = None
if item.get("modified"):
try:
modified_at = datetime.fromisoformat(item["modified"])
except (ValueError, TypeError):
pass
resources.append(
CloudResource(
id=uuid.uuid4(),
provider_item_id=item["path"],
connection_id=connection_id,
user_id=user_id,
name=item["name"],
kind=kind,
parent_ref=parent_ref,
content_type=item.get("content_type") if not item["is_dir"] else None,
size=size,
modified_at=modified_at,
etag=item.get("etag"),
)
)
return CloudListing(items=tuple(resources), complete=complete)
async def get_capabilities(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for WebDAV.
Uses non-mutating OPTIONS probe to verify server reachability.
WebDAV supports browse; mutations pending Phase 13.
Never creates, renames, moves, or deletes provider content.
"""
try:
validate_cloud_url(self._server_url)
reachable = await asyncio.to_thread(self._client.check, "/")
except Exception:
reachable = False
caps: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action == "browse":
if reachable:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_OFFLINE,
message="WebDAV server is unreachable.",
)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
return caps