feat: publish lightweight Hermes Nextcloud calendar MCP
This commit is contained in:
282
src/hermes_caldav_mcp/transport.py
Normal file
282
src/hermes_caldav_mcp/transport.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Constrain every authenticated request before constructing its headers."""
|
||||
|
||||
import base64
|
||||
import re
|
||||
import ssl
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from .types import MAX_DAV_BYTES, ErrorCode, HTTPResult, SafeError
|
||||
|
||||
|
||||
def path(value: str) -> str:
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or not value.startswith("/")
|
||||
or value.startswith("//")
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if re.search(r"[\x00-\x1f\x7f\\?#]", value) or re.search(
|
||||
r"%(?![0-9a-fA-F]{2})", value
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
segments = value.split("/")[1:]
|
||||
normalized = []
|
||||
for i, segment in enumerate(segments):
|
||||
try:
|
||||
decoded = unquote(segment, encoding="utf-8", errors="strict")
|
||||
except UnicodeError:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT) from None
|
||||
if decoded in (".", "..") or re.search(r"[%/\\\x00-\x1f\x7f]", decoded):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if not decoded and i != len(segments) - 1:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
normalized.append(quote(decoded, safe="-._~"))
|
||||
return "/" + "/".join(normalized)
|
||||
|
||||
|
||||
def strong_etag(value: str | None) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) <= 256
|
||||
and bool(re.fullmatch(r'"[\x21\x23-\x7e]*"', value))
|
||||
)
|
||||
|
||||
|
||||
def event_id(calendar, href: str) -> str:
|
||||
resource = path(href)
|
||||
root = path(calendar.href)
|
||||
if not root.endswith("/") or not resource.startswith(root):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
filename = resource[len(root) :]
|
||||
if not filename or "/" in filename:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
return base64.urlsafe_b64encode(filename.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def event_href(calendar, identifier: str) -> str:
|
||||
if not isinstance(identifier, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9_-]{1,2048}", identifier
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
try:
|
||||
filename = base64.b64decode(
|
||||
identifier + "=" * (-len(identifier) % 4), altchars=b"-_", validate=True
|
||||
).decode()
|
||||
href = path(calendar.href) + filename
|
||||
if event_id(calendar, href) != identifier:
|
||||
raise ValueError("noncanonical")
|
||||
return href
|
||||
except (ValueError, UnicodeError):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT) from None
|
||||
|
||||
|
||||
def endpoint(value: str):
|
||||
if not isinstance(value, str) or re.search(r"[\x00-\x20\x7f\\]", value):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
try:
|
||||
parts = urlsplit(value)
|
||||
if (
|
||||
parts.scheme != "https"
|
||||
or not parts.hostname
|
||||
or parts.username is not None
|
||||
or parts.password is not None
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
or "?" in value
|
||||
or "#" in value
|
||||
):
|
||||
raise ValueError("url")
|
||||
host = parts.hostname.encode("idna").decode().lower()
|
||||
if "%" in host:
|
||||
raise ValueError("host")
|
||||
port = parts.port or 443
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port")
|
||||
host_url = f"[{host}]" if ":" in host else host
|
||||
origin = f"https://{host_url}" + (f":{port}" if port != 443 else "")
|
||||
return origin, path(parts.path or "/"), (host, port)
|
||||
except (ValueError, UnicodeError):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT) from None
|
||||
|
||||
|
||||
class CalDAVClient:
|
||||
def __init__(self, profile, *, transport=None, discovery=False):
|
||||
self.write_started = False
|
||||
self.profile = profile
|
||||
self.discovery = discovery
|
||||
self.origin, base, self.authority = endpoint(profile.base_url)
|
||||
self.home = path(profile.calendar_home)
|
||||
if not self.home.startswith(
|
||||
base.rstrip("/") + "/remote.php/dav/calendars/"
|
||||
) or not self.home.endswith("/"):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
self.roots = tuple(path(c.href) for c in profile.calendars)
|
||||
if any(
|
||||
not root.startswith(self.home)
|
||||
or not root.endswith("/")
|
||||
or "/" in root[len(self.home) : -1]
|
||||
or root == self.home
|
||||
for root in self.roots
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if (
|
||||
not profile.username
|
||||
or ":" in profile.username
|
||||
or any(ord(c) < 32 for c in profile.username)
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
try:
|
||||
self.ssl_context = ssl.create_default_context(cafile=profile.ca_bundle)
|
||||
except (OSError, ssl.SSLError):
|
||||
raise SafeError(ErrorCode.NETWORK_ERROR) from None
|
||||
self.http = httpx.AsyncClient(
|
||||
verify=self.ssl_context,
|
||||
trust_env=False,
|
||||
follow_redirects=False,
|
||||
transport=transport,
|
||||
timeout=httpx.Timeout(20, connect=10),
|
||||
limits=httpx.Limits(max_connections=2, max_keepalive_connections=0),
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.http.aclose()
|
||||
|
||||
def validated_href(self, href: str, *, discovered=False) -> str:
|
||||
if href.startswith("https://"):
|
||||
origin, resource, authority = endpoint(href)
|
||||
if authority != self.authority or origin != self.origin:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
else:
|
||||
resource = path(href)
|
||||
if discovered:
|
||||
if resource == self.home:
|
||||
return resource
|
||||
if (
|
||||
resource.startswith(self.home)
|
||||
and resource.endswith("/")
|
||||
and "/" not in resource[len(self.home) : -1]
|
||||
and resource != self.home
|
||||
):
|
||||
return resource
|
||||
for root in self.roots:
|
||||
if resource == root or (
|
||||
resource.startswith(root)
|
||||
and resource[len(root) :]
|
||||
and "/" not in resource[len(root) :]
|
||||
):
|
||||
return resource
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
href: str,
|
||||
*,
|
||||
body=None,
|
||||
expected_revision=None,
|
||||
create=False,
|
||||
max_bytes=MAX_DAV_BYTES,
|
||||
) -> HTTPResult:
|
||||
if (
|
||||
method not in {"PROPFIND", "REPORT", "GET", "PUT"}
|
||||
or not 1 <= max_bytes <= MAX_DAV_BYTES
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
resource = self.validated_href(href, discovered=self.discovery)
|
||||
if self.discovery:
|
||||
if method != "PROPFIND" or resource != self.home:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
elif (method in {"REPORT", "PROPFIND"}) != resource.endswith("/"):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if method != "PUT" and (create or expected_revision is not None):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
headers = {"Accept-Encoding": "identity"}
|
||||
if method == "PROPFIND":
|
||||
headers.update(
|
||||
{
|
||||
"Depth": "1" if self.discovery else "0",
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
}
|
||||
)
|
||||
elif method == "REPORT":
|
||||
headers.update(
|
||||
{"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}
|
||||
)
|
||||
elif method == "PUT":
|
||||
if create == (expected_revision is not None) or (
|
||||
expected_revision is not None and not strong_etag(expected_revision)
|
||||
):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
headers["Content-Type"] = "text/calendar; charset=utf-8"
|
||||
headers["If-None-Match" if create else "If-Match"] = (
|
||||
"*" if create else expected_revision
|
||||
)
|
||||
try:
|
||||
if method == "PUT":
|
||||
self.write_started = True
|
||||
async with self.http.stream(
|
||||
method,
|
||||
self.origin + resource,
|
||||
headers=headers,
|
||||
content=body,
|
||||
auth=httpx.BasicAuth(self.profile.username, self.profile.password),
|
||||
) as response:
|
||||
status = response.status_code
|
||||
failures = {
|
||||
401: ErrorCode.AUTH_FAILED,
|
||||
403: ErrorCode.PERMISSION_DENIED,
|
||||
404: ErrorCode.NOT_FOUND,
|
||||
412: ErrorCode.CONFLICT,
|
||||
}
|
||||
if status in failures:
|
||||
raise SafeError(failures[status])
|
||||
allowed = {
|
||||
"PROPFIND": {207},
|
||||
"REPORT": {207},
|
||||
"GET": {200},
|
||||
"PUT": {200, 201, 204},
|
||||
}
|
||||
if status not in allowed[method]:
|
||||
if method == "PUT" and status >= 500:
|
||||
raise SafeError(ErrorCode.UNKNOWN_WRITE_OUTCOME)
|
||||
raise SafeError(ErrorCode.BAD_RESPONSE)
|
||||
etag = response.headers.get("ETag")
|
||||
if method == "PUT":
|
||||
# Status and ETag suffice. Consuming an unused response body
|
||||
# could turn an acknowledged write into a false failure.
|
||||
return HTTPResult(status, b"", etag if strong_etag(etag) else None)
|
||||
# Reject compressed responses to bound decompression memory as well as bytes.
|
||||
if (
|
||||
response.headers.get("Content-Encoding", "identity").lower()
|
||||
!= "identity"
|
||||
):
|
||||
raise SafeError(ErrorCode.BAD_RESPONSE)
|
||||
length = response.headers.get("Content-Length")
|
||||
if length is not None:
|
||||
try:
|
||||
if int(length) < 0:
|
||||
raise ValueError("length")
|
||||
if int(length) > max_bytes:
|
||||
raise SafeError(ErrorCode.LIMIT_EXCEEDED)
|
||||
except ValueError:
|
||||
raise SafeError(ErrorCode.BAD_RESPONSE) from None
|
||||
data = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
if len(data) + len(chunk) > max_bytes:
|
||||
raise SafeError(ErrorCode.LIMIT_EXCEEDED)
|
||||
data.extend(chunk)
|
||||
etag = response.headers.get("ETag")
|
||||
return HTTPResult(
|
||||
status, bytes(data), etag if strong_etag(etag) else None
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise SafeError(
|
||||
ErrorCode.UNKNOWN_WRITE_OUTCOME
|
||||
if method == "PUT"
|
||||
else ErrorCode.NETWORK_ERROR
|
||||
) from None
|
||||
Reference in New Issue
Block a user