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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user