Files
espresso_frame/server/app/webdav_client.py
T
tfaour b4ca795003
Build and push server image / build-and-push (push) Successful in 1m57s
Add a whiteboard file picker and force-refresh
File picker: an optional WebDAV browse root in Settings
(User.webdav_base_url) plus a plain-PROPFIND directory listing
(webdav_client.list_directory) power a "Browse..." panel on a frame's
Whiteboard tab, so a file can be clicked into rather than typing its
exact WebDAV URL. Manual URL entry still works unchanged either way.

Force-refresh: get_or_refresh_whiteboard takes a force flag that skips
the fetch throttle entirely; the Whiteboard tab's refresh button now
passes it, so clicking it always re-fetches and re-renders instead of
possibly just re-showing the same cached image from within the last
~20 minutes.
2026-07-23 18:20:39 -04:00

123 lines
5.1 KiB
Python

"""Plain authenticated WebDAV file fetch -- whiteboard frame mode's way
of pulling one specific file (a Nextcloud Whiteboard .whiteboard, or any
other WebDAV server's file, this isn't Nextcloud-specific) out of a
user's account. Deliberately just "GET this URL with Basic auth", the
same shape as calendar_feed.py's plain ICS fetch -- no discovery, no
account-wide browsing, since the caller already has (or pastes) the
exact file URL, unlike caldav_client.py's calendar-account discovery
flow which exists because a CalDAV account can hold several calendars
worth picking between.
Pure functions -- no ORM, no FastAPI Depends -- same testability
philosophy as calendar_feed.py/caldav_client.py.
"""
from __future__ import annotations
from urllib.parse import unquote, urljoin, urlsplit, urlunsplit
from xml.etree import ElementTree
import httpx
HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # a whiteboard scene is KB, not MB -- sanity cap, not an expected size
_DAV_NS = "DAV:"
_PROPFIND_BODY = (
'<?xml version="1.0" encoding="utf-8" ?>'
f'<d:propfind xmlns:d="{_DAV_NS}"><d:prop>'
"<d:displayname/><d:resourcetype/></d:prop></d:propfind>"
)
class WebDavError(Exception):
"""Fetch failed -- network, auth, a missing file, or an oversized
response. Raised loudly; callers decide what to do."""
def fetch_file(url: str, username: str, password: str) -> bytes:
"""The raw bytes of one WebDAV file, HTTP Basic auth. That's the
whole protocol surface whiteboard mode needs -- Basic auth over
plain HTTP GET is what WebDAV file access boils down to once you
already have the exact URL, no PROPFIND/discovery involved."""
try:
with httpx.stream("GET", url, auth=(username, password), timeout=HTTP_TIMEOUT_S,
follow_redirects=True) as resp:
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > FETCH_MAX_BYTES:
raise WebDavError(f"File exceeds {FETCH_MAX_BYTES} bytes")
chunks.append(chunk)
return b"".join(chunks)
except httpx.HTTPError as e:
raise WebDavError(str(e)) from e
def list_directory(url: str, username: str, password: str) -> list[dict]:
"""One level of a WebDAV directory listing -- name/url/is_dir for
each entry, folders first then alphabetical. Powers the whiteboard
file-picker (browse instead of paste the exact file URL); a plain
Depth-1 PROPFIND for displayname + resourcetype is all that needs,
same "just enough protocol, not a full WebDAV client" scope as
fetch_file above."""
try:
resp = httpx.request(
"PROPFIND", url, auth=(username, password), timeout=HTTP_TIMEOUT_S,
follow_redirects=True, headers={"Depth": "1", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
resp.raise_for_status()
except httpx.HTTPError as e:
raise WebDavError(str(e)) from e
try:
root = ElementTree.fromstring(resp.content)
except ElementTree.ParseError as e:
raise WebDavError(f"Server returned an unparseable directory listing: {e}") from e
self_path = urlsplit(url).path.rstrip("/")
entries = []
for response_el in root.findall(f"{{{_DAV_NS}}}response"):
href_el = response_el.find(f"{{{_DAV_NS}}}href")
if href_el is None or not href_el.text:
continue
href = href_el.text
if urlsplit(href).path.rstrip("/") == self_path:
continue # the listed directory's own entry, not a child
propstat = response_el.find(f"{{{_DAV_NS}}}propstat")
prop = propstat.find(f"{{{_DAV_NS}}}prop") if propstat is not None else None
resourcetype = prop.find(f"{{{_DAV_NS}}}resourcetype") if prop is not None else None
is_dir = resourcetype is not None and resourcetype.find(f"{{{_DAV_NS}}}collection") is not None
displayname_el = prop.find(f"{{{_DAV_NS}}}displayname") if prop is not None else None
name = (displayname_el.text or "").strip() if displayname_el is not None else ""
if not name:
name = unquote(href.rstrip("/").rsplit("/", 1)[-1])
if not name:
continue
entries.append({"name": name, "url": urljoin(url, href), "is_dir": is_dir})
entries.sort(key=lambda e: (not e["is_dir"], e["name"].casefold()))
return entries
def parent_directory_url(base_url: str, current_url: str) -> str | None:
"""One level up from `current_url`, or None if `current_url` is
already at (or above) `base_url` -- the file-picker's "Up" button
doesn't wander outside the folder the user configured as their
browse root in Settings."""
base_path = urlsplit(base_url).path.rstrip("/")
current = urlsplit(current_url)
current_path = current.path.rstrip("/")
if len(current_path) <= len(base_path):
return None
parent_path = current_path.rsplit("/", 1)[0] or "/"
if len(parent_path) < len(base_path):
parent_path = base_path
return urlunsplit((current.scheme, current.netloc, parent_path + "/", "", ""))