feat(12.1-01): repair Nextcloud adapter contract and add URL normalization
- Remove incompatible NextcloudBackend.list_folder(folder_path="") override so Nextcloud inherits canonical WebDAVBackend.list_folder(connection_id, user_id, parent_ref=None, page_token=None) -> CloudListing - Add normalize_nextcloud_url() to cloud_utils.py: idempotent canonical DAV root derivation, username percent-encoding, https-only, rejects userinfo/ query/fragment, validates via SSRF guard before return - Use normalize_nextcloud_url in cloud_backend_factory for nextcloud provider - Add TestNextcloudBackendNoListFolderOverride and TestNextcloudUrlNormalization to test_webdav_backend.py - All 9 Nextcloud contract failures now pass; full focused suite: 158 passed
This commit is contained in:
@@ -1,24 +1,24 @@
|
||||
"""
|
||||
NextcloudBackend — Nextcloud-specific WebDAV StorageBackend implementation.
|
||||
|
||||
Extends WebDAVBackend with Nextcloud path conventions and folder listing.
|
||||
Extends WebDAVBackend with Nextcloud path conventions.
|
||||
|
||||
Inheritance:
|
||||
NextcloudBackend → WebDAVBackend → StorageBackend
|
||||
|
||||
All 7 StorageBackend abstract methods are inherited from WebDAVBackend.
|
||||
list_folder is inherited from WebDAVBackend — NextcloudBackend does NOT
|
||||
override it so that both providers share exactly one canonical adapter
|
||||
implementation (Phase 12.1 P0 fix).
|
||||
|
||||
Only health_check is overridden to use the Nextcloud root (empty string
|
||||
is a valid check target for Nextcloud's WebDAV endpoint).
|
||||
|
||||
Additional method (not in ABC):
|
||||
list_folder(folder_path: str) → list[dict]
|
||||
Lists folder contents via client.list() + client.info(). Used by:
|
||||
GET /api/cloud/folders/nextcloud/{folder_id} (D-09, lazy-load tree).
|
||||
|
||||
Nextcloud WebDAV path convention:
|
||||
The server_url should be the Nextcloud WebDAV base for a specific user:
|
||||
The server_url must be the normalized Nextcloud WebDAV root for a user:
|
||||
https://nc.example.com/remote.php/dav/files/{username}/
|
||||
Object keys are then relative to this root (inherited from WebDAVBackend).
|
||||
Use normalize_nextcloud_url(base_url, username) at construction time.
|
||||
Object keys are relative to this root (inherited from WebDAVBackend).
|
||||
|
||||
SSRF prevention:
|
||||
Inherited from WebDAVBackend.__init__ — validate_cloud_url(server_url) is
|
||||
@@ -29,8 +29,6 @@ Credentials dict shape (same as WebDAVBackend):
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from storage.cloud_utils import validate_cloud_url
|
||||
from storage.webdav_backend import WebDAVBackend
|
||||
|
||||
@@ -38,10 +36,14 @@ from storage.webdav_backend import WebDAVBackend
|
||||
class NextcloudBackend(WebDAVBackend):
|
||||
"""Nextcloud storage backend — extends WebDAVBackend.
|
||||
|
||||
The server_url should be the full Nextcloud WebDAV root:
|
||||
The server_url must be the full normalized Nextcloud WebDAV root:
|
||||
https://nc.example.com/remote.php/dav/files/{username}/
|
||||
|
||||
All 7 StorageBackend methods are inherited from WebDAVBackend.
|
||||
All 7 StorageBackend methods and list_folder (CloudResourceAdapter) are
|
||||
inherited from WebDAVBackend — no public override. Nextcloud-specific
|
||||
URL normalization is handled by normalize_nextcloud_url() in cloud_utils
|
||||
before construction.
|
||||
|
||||
The SSRF guard is fully inherited — any private/localhost URL raises
|
||||
ValueError at construction time (and before every outbound request).
|
||||
"""
|
||||
@@ -51,6 +53,7 @@ class NextcloudBackend(WebDAVBackend):
|
||||
|
||||
Args:
|
||||
server_url: Nextcloud WebDAV root URL.
|
||||
Should be pre-normalized via normalize_nextcloud_url().
|
||||
E.g. "https://nc.example.com/remote.php/dav/files/alice/"
|
||||
username: Nextcloud username (stored for path convention context).
|
||||
password: Nextcloud account password or app-specific password (D-07).
|
||||
@@ -61,79 +64,11 @@ class NextcloudBackend(WebDAVBackend):
|
||||
super().__init__(server_url, username, password)
|
||||
self._username = username
|
||||
|
||||
async def list_folder(self, folder_path: str = "") -> list[dict]:
|
||||
"""List folder contents at folder_path relative to the WebDAV root.
|
||||
|
||||
Performs a PROPFIND (via client.list()) and fetches metadata for each
|
||||
item via client.info(). Both calls are wrapped in asyncio.to_thread().
|
||||
|
||||
SSRF guard is called before every outbound request (D-17).
|
||||
|
||||
TTL caching at 60 seconds is handled by cloud_cache.get_cloud_folders_cached()
|
||||
at the API layer — this method always performs live PROPFIND calls.
|
||||
|
||||
Args:
|
||||
folder_path: Path relative to the WebDAV root to list.
|
||||
Empty string or "/" lists the WebDAV root.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys:
|
||||
- "id" (str): WebDAV path (usable as object_key or folder_id)
|
||||
- "name" (str): File or folder display name
|
||||
- "is_dir" (bool): True if the item is a directory
|
||||
- "size" (int): File size in bytes (0 for directories)
|
||||
|
||||
Raises:
|
||||
ValueError: If SSRF guard fires on re-validation.
|
||||
Exception: Propagates any webdavclient3 exceptions (e.g. connection errors).
|
||||
"""
|
||||
# Re-validate before every outbound request (D-17 / T-05-04-02)
|
||||
validate_cloud_url(self._server_url)
|
||||
|
||||
# client.list() returns a list of file/folder names in the directory
|
||||
items = await asyncio.to_thread(self._client.list, folder_path)
|
||||
|
||||
folder_norm = folder_path.strip("/")
|
||||
result: list[dict] = []
|
||||
for name in items:
|
||||
# Skip the "." self-reference and empty entries
|
||||
if not name or name in (".", "./"):
|
||||
continue
|
||||
# Skip the directory itself (PROPFIND Depth:1 always includes the parent)
|
||||
if name.strip("/") == folder_norm:
|
||||
continue
|
||||
|
||||
# Construct the full path for info lookup
|
||||
if folder_path:
|
||||
item_path = f"{folder_path.rstrip('/')}/{name}"
|
||||
else:
|
||||
item_path = name
|
||||
|
||||
# Fetch metadata for each item
|
||||
validate_cloud_url(self._server_url)
|
||||
try:
|
||||
info = await asyncio.to_thread(self._client.info, item_path)
|
||||
size = int(info.get("size", 0))
|
||||
# webdavclient3 info dict uses "isdir" or checks content_type
|
||||
# is_dir is True if size == 0 and name ends with "/" or info signals it
|
||||
is_dir = info.get("isdir", False) or str(info.get("content_type", "")).startswith(
|
||||
"httpd/unix-directory"
|
||||
) or name.endswith("/")
|
||||
except Exception:
|
||||
# If we can't get info for an item, include it with defaults
|
||||
size = 0
|
||||
is_dir = name.endswith("/")
|
||||
|
||||
result.append(
|
||||
{
|
||||
"id": item_path,
|
||||
"name": name.rstrip("/"),
|
||||
"is_dir": is_dir,
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
# list_folder is intentionally NOT overridden.
|
||||
# NextcloudBackend inherits WebDAVBackend.list_folder which implements the
|
||||
# canonical (connection_id, user_id, parent_ref=None, page_token=None) -> CloudListing
|
||||
# contract. A legacy list_folder(folder_path="") -> list[dict] override was
|
||||
# the root cause of the Phase 12.1 P0 defect (TypeError on canonical invocation).
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Return True if the Nextcloud WebDAV endpoint is reachable.
|
||||
|
||||
Reference in New Issue
Block a user