App passwords (SPEC App. D §D.5): one generated password per app, shown once, revocable on its own, revoked on a password reset; never accepted by the web sign-in. Settings → Security → App passwords (migration 0024). CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with /.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks, throttled failures). The calendar and contacts plugins serve their own tables as collections (Plugin(dav=...)): - calendars as iCalendar: series with overrides, time zones, all-day, attendees and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars; subscriptions read-only; writes to upstream-synced calendars flow on upstream; - address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES; - what a client stores is served back verbatim until the web app edits it; - sync tokens / CTags from change logs filled by triggers (calendar0008, contacts0002), so every write path counts; trimmed nightly after 90 days. REPORTs: calendar-query (time range), calendar-/addressbook-multiget, addressbook-query, sync-collection. nginx and the Vite proxy route /dav/. Settings shows the server and user name to enter; install guide §5.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
596 lines
23 KiB
Python
596 lines
23 KiB
Python
"""The CalDAV / CardDAV endpoints (SPEC Appendix E).
|
|
|
|
/.well-known/caldav, /.well-known/carddav → 301 /dav/
|
|
/dav/ root: points at the principal
|
|
/dav/principals/{user}/ the signed-in user
|
|
/dav/calendars/{user}/[{calendar}/[{name}]] calendar home, calendars, events
|
|
/dav/addressbooks/{user}/[{book}/[{name}]] address-book home, books, contacts
|
|
|
|
The data comes from the plugins' providers (app/dav/provider.py): the calendar
|
|
plugin's for ``calendars``, the contacts plugin's for ``addressbooks``. Signed
|
|
in with HTTP Basic and an app password only (app/dav/auth.py); session cookies
|
|
are never looked at, so a web page cannot drive these endpoints.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Any
|
|
from urllib.parse import quote, unquote, urlsplit
|
|
|
|
from fastapi import APIRouter, Request, Response
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import select
|
|
|
|
from app.dav import auth
|
|
from app.dav.provider import Collection, DavError, DavProvider, Resource
|
|
from app.dav.xml import (
|
|
CR,
|
|
CS,
|
|
A,
|
|
BadRequest,
|
|
C,
|
|
D,
|
|
Multistatus,
|
|
PropValue,
|
|
el,
|
|
error_body,
|
|
href,
|
|
mkcol_props,
|
|
parse,
|
|
propfind_request,
|
|
proppatch_request,
|
|
q,
|
|
report_request,
|
|
)
|
|
from app.db import session_scope
|
|
from app.models import Account, User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
METHODS = [
|
|
"OPTIONS",
|
|
"PROPFIND",
|
|
"PROPPATCH",
|
|
"REPORT",
|
|
"GET",
|
|
"HEAD",
|
|
"PUT",
|
|
"DELETE",
|
|
"MKCALENDAR",
|
|
"MKCOL",
|
|
]
|
|
DAV_HEADER = "1, 3, calendar-access, addressbook, extended-mkcol"
|
|
XML_TYPE = "application/xml; charset=utf-8"
|
|
HOMES = {"calendars": "calendar", "addressbooks": "addressbook"}
|
|
SYNC_PREFIX = "http://ultimo.email/ns/sync/"
|
|
|
|
|
|
def _providers() -> dict[str, DavProvider]:
|
|
from app.plugins import loader
|
|
|
|
out: dict[str, DavProvider] = {}
|
|
for plugin in loader.get_plugins():
|
|
if plugin.dav is not None:
|
|
out[plugin.dav.kind] = plugin.dav
|
|
return out
|
|
|
|
|
|
# ---- entry points ------------------------------------------------------------------
|
|
|
|
|
|
@router.api_route("/.well-known/caldav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
|
|
@router.api_route("/.well-known/carddav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
|
|
async def well_known() -> Response:
|
|
return RedirectResponse("/dav/", status_code=301)
|
|
|
|
|
|
@router.api_route("/dav", methods=METHODS)
|
|
@router.api_route("/dav/{path:path}", methods=METHODS)
|
|
async def dav(request: Request, path: str = "") -> Response:
|
|
method = request.method.upper()
|
|
if method == "OPTIONS":
|
|
return Response(
|
|
status_code=200,
|
|
headers={"DAV": DAV_HEADER, "Allow": ", ".join(METHODS), "Content-Length": "0"},
|
|
)
|
|
ip = request.client.host if request.client else ""
|
|
try:
|
|
user = await auth.authenticate(request.headers.get("authorization"), ip)
|
|
except auth.Throttled:
|
|
return Response(status_code=429, headers={"Retry-After": "900"})
|
|
if user is None:
|
|
return Response(
|
|
status_code=401,
|
|
headers={"WWW-Authenticate": 'Basic realm="Ultimo", charset="UTF-8"'},
|
|
)
|
|
parts = [unquote(p) for p in path.split("/") if p]
|
|
ctx = _Ctx(request, user, parts)
|
|
try:
|
|
return await ctx.handle(method)
|
|
except BadRequest as err:
|
|
return Response(status_code=400, content=str(err))
|
|
except DavError as err:
|
|
body = error_body(err.condition) if err.condition else b""
|
|
return Response(
|
|
status_code=err.status,
|
|
content=body,
|
|
media_type=XML_TYPE if body else None,
|
|
)
|
|
|
|
|
|
class _Ctx:
|
|
def __init__(self, request: Request, user: User, parts: list[str]) -> None:
|
|
self.request = request
|
|
self.user = user
|
|
self.uid = str(user.id)
|
|
self.parts = parts
|
|
self.providers = _providers()
|
|
|
|
# -- paths -----------------------------------------------------------------------
|
|
|
|
def principal(self) -> str:
|
|
return f"/dav/principals/{self.uid}/"
|
|
|
|
def home(self, segment: str) -> str:
|
|
return f"/dav/{segment}/{self.uid}/"
|
|
|
|
def coll_href(self, segment: str, coll_id: str) -> str:
|
|
return f"{self.home(segment)}{quote(coll_id, safe='')}/"
|
|
|
|
def res_href(self, segment: str, coll_id: str, name: str) -> str:
|
|
return f"{self.coll_href(segment, coll_id)}{quote(name, safe='')}"
|
|
|
|
# -- dispatch --------------------------------------------------------------------
|
|
|
|
async def handle(self, method: str) -> Response:
|
|
p = self.parts
|
|
if not p:
|
|
return await self.root(method)
|
|
if p[0] == "principals":
|
|
if len(p) != 2 or p[1] != self.uid:
|
|
raise DavError(404)
|
|
return await self.principal_req(method)
|
|
if p[0] in HOMES and len(p) >= 2:
|
|
if p[1] != self.uid:
|
|
raise DavError(403)
|
|
provider = self.providers.get(HOMES[p[0]])
|
|
if provider is None:
|
|
raise DavError(404)
|
|
if len(p) == 2:
|
|
return await self.home_req(method, p[0], provider)
|
|
if len(p) == 3:
|
|
return await self.collection_req(method, p[0], provider, p[2])
|
|
if len(p) == 4:
|
|
return await self.resource_req(method, p[0], provider, p[2], p[3])
|
|
raise DavError(404)
|
|
|
|
async def body(self) -> ET.Element | None:
|
|
return parse(await self.request.body())
|
|
|
|
def depth(self) -> int:
|
|
value = (self.request.headers.get("depth") or "1").strip().lower()
|
|
return 0 if value == "0" else 1
|
|
|
|
@staticmethod
|
|
def multistatus(ms: Multistatus) -> Response:
|
|
return Response(status_code=207, content=ms.to_bytes(), media_type=XML_TYPE)
|
|
|
|
async def respond_props(
|
|
self, items: list[tuple[str, dict[str, PropValue]]], method_root: ET.Element | None
|
|
) -> Response:
|
|
req = propfind_request(method_root)
|
|
ms = Multistatus()
|
|
for path, props in items:
|
|
if req.names_only:
|
|
ms.response(path, dict.fromkeys(props))
|
|
elif req.names is None:
|
|
ms.response(path, props)
|
|
else:
|
|
found = {n: props[n] for n in req.names if n in props}
|
|
missing = [n for n in req.names if n not in props]
|
|
ms.response(path, found, missing)
|
|
return self.multistatus(ms)
|
|
|
|
# -- root and principal ----------------------------------------------------------
|
|
|
|
def common(self) -> dict[str, PropValue]:
|
|
return {
|
|
q(D, "current-user-principal"): [href(self.principal())],
|
|
q(D, "principal-URL"): [href(self.principal())],
|
|
q(D, "owner"): [href(self.principal())],
|
|
}
|
|
|
|
async def root(self, method: str) -> Response:
|
|
if method != "PROPFIND":
|
|
return Response(status_code=200 if method in ("GET", "HEAD") else 405)
|
|
props = {**self.common(), q(D, "resourcetype"): [el(q(D, "collection"))]}
|
|
return await self.respond_props([("/dav/", props)], await self.body())
|
|
|
|
async def addresses(self) -> list[str]:
|
|
async with session_scope() as db:
|
|
emails = list(
|
|
await db.scalars(select(Account.email).where(Account.user_id == self.user.id))
|
|
)
|
|
out = [self.user.handle, *emails]
|
|
return list(dict.fromkeys(e.lower() for e in out))
|
|
|
|
async def principal_props(self) -> dict[str, PropValue]:
|
|
props = {
|
|
**self.common(),
|
|
q(D, "resourcetype"): [el(q(D, "principal")), el(q(D, "collection"))],
|
|
q(D, "displayname"): self.user.handle,
|
|
q(C, "calendar-user-address-set"): [
|
|
href(f"mailto:{a}") for a in await self.addresses()
|
|
],
|
|
q(C, "calendar-user-type"): "INDIVIDUAL",
|
|
q(D, "principal-collection-set"): [href("/dav/principals/")],
|
|
q(D, "supported-report-set"): [],
|
|
}
|
|
if "calendar" in self.providers:
|
|
props[q(C, "calendar-home-set")] = [href(self.home("calendars"))]
|
|
if "addressbook" in self.providers:
|
|
props[q(CR, "addressbook-home-set")] = [href(self.home("addressbooks"))]
|
|
return props
|
|
|
|
async def principal_req(self, method: str) -> Response:
|
|
if method == "PROPFIND":
|
|
return await self.respond_props(
|
|
[(self.principal(), await self.principal_props())], await self.body()
|
|
)
|
|
if method == "REPORT": # principal-property-search, expand-property: nothing to add
|
|
return self.multistatus(Multistatus())
|
|
return Response(status_code=405)
|
|
|
|
# -- homes -------------------------------------------------------------------------
|
|
|
|
def privileges(self, read_only: bool) -> list[ET.Element]:
|
|
names = ["read", "read-current-user-privilege-set"]
|
|
if not read_only:
|
|
names += ["write", "write-content", "write-properties", "bind", "unbind"]
|
|
return [el(q(D, "privilege"), children=[el(q(D, n))]) for n in names]
|
|
|
|
def reports(self, kind: str) -> list[ET.Element]:
|
|
ns, reports = (
|
|
(C, ["calendar-query", "calendar-multiget"])
|
|
if kind == "calendar"
|
|
else (CR, ["addressbook-query", "addressbook-multiget"])
|
|
)
|
|
items = [(ns, r) for r in reports] + [(D, "sync-collection")]
|
|
return [
|
|
el(q(D, "supported-report"), children=[el(q(D, "report"), children=[el(q(n, r))])])
|
|
for n, r in items
|
|
]
|
|
|
|
async def home_req(self, method: str, segment: str, provider: DavProvider) -> Response:
|
|
if method == "PROPFIND":
|
|
home_props: dict[str, PropValue] = {
|
|
**self.common(),
|
|
q(D, "resourcetype"): [el(q(D, "collection"))],
|
|
q(D, "displayname"): "Calendars" if provider.kind == "calendar" else "Contacts",
|
|
q(D, "current-user-privilege-set"): self.privileges(False),
|
|
}
|
|
items = [(self.home(segment), home_props)]
|
|
if self.depth():
|
|
for coll in await provider.collections(self.user.id):
|
|
items.append(
|
|
(self.coll_href(segment, coll.id), self.collection_props(provider, coll))
|
|
)
|
|
return await self.respond_props(items, await self.body())
|
|
if method == "REPORT":
|
|
return self.multistatus(Multistatus())
|
|
if method in ("GET", "HEAD"):
|
|
return Response(status_code=200)
|
|
return Response(status_code=405)
|
|
|
|
# -- collections -------------------------------------------------------------------
|
|
|
|
def collection_props(self, provider: DavProvider, coll: Collection) -> dict[str, PropValue]:
|
|
token = f"{SYNC_PREFIX}{coll.sync_token}"
|
|
props: dict[str, PropValue] = {
|
|
**self.common(),
|
|
q(D, "displayname"): coll.displayname,
|
|
q(D, "current-user-privilege-set"): self.privileges(coll.read_only),
|
|
q(D, "supported-report-set"): self.reports(provider.kind),
|
|
q(D, "sync-token"): token,
|
|
q(CS, "getctag"): str(coll.sync_token),
|
|
}
|
|
if provider.kind == "calendar":
|
|
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(C, "calendar"))]
|
|
props[q(C, "supported-calendar-component-set")] = [
|
|
el(q(C, "comp"), attrs={"name": comp}) for comp in coll.components
|
|
]
|
|
props[q(C, "supported-calendar-data")] = [
|
|
el(q(C, "calendar-data"), attrs={"content-type": "text/calendar", "version": "2.0"})
|
|
]
|
|
props[q(C, "calendar-description")] = coll.description
|
|
if coll.color:
|
|
props[q(A, "calendar-color")] = coll.color
|
|
else:
|
|
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(CR, "addressbook"))]
|
|
props[q(CR, "supported-address-data")] = [
|
|
el(
|
|
q(CR, "address-data-type"),
|
|
attrs={"content-type": "text/vcard", "version": "3.0"},
|
|
),
|
|
el(
|
|
q(CR, "address-data-type"),
|
|
attrs={"content-type": "text/vcard", "version": "4.0"},
|
|
),
|
|
]
|
|
props[q(CR, "addressbook-description")] = coll.description
|
|
return props
|
|
|
|
async def find(self, provider: DavProvider, coll_id: str) -> Collection:
|
|
for coll in await provider.collections(self.user.id):
|
|
if coll.id == coll_id:
|
|
return coll
|
|
raise DavError(404)
|
|
|
|
def resource_props(
|
|
self, provider: DavProvider, res: Resource, with_data: bool
|
|
) -> dict[str, PropValue]:
|
|
props: dict[str, PropValue] = {
|
|
q(D, "getetag"): f'"{res.etag}"',
|
|
q(D, "getcontenttype"): provider.content_type,
|
|
q(D, "resourcetype"): [],
|
|
}
|
|
if with_data and res.data is not None:
|
|
data_prop = (
|
|
q(C, "calendar-data") if provider.kind == "calendar" else q(CR, "address-data")
|
|
)
|
|
props[data_prop] = res.data
|
|
return props
|
|
|
|
async def collection_req(
|
|
self, method: str, segment: str, provider: DavProvider, coll_id: str
|
|
) -> Response:
|
|
if method == "MKCALENDAR" or method == "MKCOL":
|
|
return await self.make_collection(segment, provider, coll_id)
|
|
coll = await self.find(provider, coll_id)
|
|
if method == "PROPFIND":
|
|
items = [(self.coll_href(segment, coll.id), self.collection_props(provider, coll))]
|
|
if self.depth():
|
|
for res in await provider.resources(self.user.id, coll.id):
|
|
items.append(
|
|
(
|
|
self.res_href(segment, coll.id, res.name),
|
|
self.resource_props(provider, res, False),
|
|
)
|
|
)
|
|
return await self.respond_props(items, await self.body())
|
|
if method == "REPORT":
|
|
return await self.report(segment, provider, coll)
|
|
if method == "PROPPATCH":
|
|
return await self.proppatch(segment, provider, coll)
|
|
if method == "DELETE":
|
|
if coll.read_only:
|
|
raise DavError(403)
|
|
await provider.delete_collection(self.user.id, coll.id)
|
|
return Response(status_code=204)
|
|
if method in ("GET", "HEAD"):
|
|
return Response(status_code=200)
|
|
return Response(status_code=405)
|
|
|
|
async def make_collection(self, segment: str, provider: DavProvider, coll_id: str) -> Response:
|
|
if any(c.id == coll_id for c in await provider.collections(self.user.id)):
|
|
raise DavError(405) # exists already
|
|
root = await self.body()
|
|
props = mkcol_props(root)
|
|
rtype = props.get(q(D, "resourcetype"))
|
|
if self.request.method.upper() == "MKCOL" and (
|
|
rtype is None
|
|
or rtype.find(q(CR, "addressbook")) is None
|
|
or provider.kind != "addressbook"
|
|
):
|
|
raise DavError(403)
|
|
if self.request.method.upper() == "MKCALENDAR" and provider.kind != "calendar":
|
|
raise DavError(403)
|
|
name_el = props.get(q(D, "displayname"))
|
|
color_el = props.get(q(A, "calendar-color"))
|
|
await provider.create_collection(
|
|
self.user.id,
|
|
coll_id,
|
|
(name_el.text or "").strip() if name_el is not None else "",
|
|
(color_el.text or "").strip() if color_el is not None else None,
|
|
)
|
|
return Response(status_code=201)
|
|
|
|
async def proppatch(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
|
|
patch = proppatch_request(await self.body())
|
|
if coll.read_only:
|
|
raise DavError(403)
|
|
values: dict[str, Any] = {}
|
|
for name, value in patch.set.items():
|
|
text = (value.text or "").strip()
|
|
if name == q(D, "displayname"):
|
|
values["displayname"] = text
|
|
elif name == q(A, "calendar-color"):
|
|
values["color"] = text
|
|
elif name in (q(C, "calendar-description"), q(CR, "addressbook-description")):
|
|
values["description"] = text
|
|
if values:
|
|
await provider.update_collection(self.user.id, coll.id, **values)
|
|
# Properties not kept (calendar order, …) are accepted and ignored:
|
|
# clients treat a refusal as a failed sync.
|
|
ms = Multistatus()
|
|
ms.response(self.coll_href(segment, coll.id), dict.fromkeys([*patch.set, *patch.remove]))
|
|
return self.multistatus(ms)
|
|
|
|
async def report(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
|
|
root = await self.body()
|
|
if root is None:
|
|
raise BadRequest("empty report")
|
|
rep = report_request(root)
|
|
wants_data = rep.props is None or any(
|
|
p in (q(C, "calendar-data"), q(CR, "address-data")) for p in rep.props
|
|
)
|
|
ms = Multistatus()
|
|
|
|
def add(res: Resource) -> None:
|
|
props = self.resource_props(provider, res, wants_data)
|
|
if rep.props is None:
|
|
ms.response(self.res_href(segment, coll.id, res.name), props)
|
|
else:
|
|
found = {n: props[n] for n in rep.props if n in props}
|
|
missing = [n for n in rep.props if n not in props]
|
|
ms.response(self.res_href(segment, coll.id, res.name), found, missing)
|
|
|
|
if rep.kind in ("calendar-multiget", "addressbook-multiget"):
|
|
names = [_last_segment(h) for h in rep.hrefs]
|
|
got = {r.name: r for r in await provider.get(self.user.id, coll.id, names)}
|
|
for h, name in zip(rep.hrefs, names, strict=True):
|
|
if name in got:
|
|
add(got[name])
|
|
else:
|
|
ms.response(h, {}, status=404)
|
|
return self.multistatus(ms)
|
|
|
|
if rep.kind in ("calendar-query", "addressbook-query"):
|
|
names_q = (
|
|
await provider.query(self.user.id, coll.id, rep.start, rep.end)
|
|
if rep.kind == "calendar-query"
|
|
else None
|
|
)
|
|
for res in (
|
|
await provider.get(self.user.id, coll.id, names_q)
|
|
if wants_data
|
|
else (
|
|
await provider.resources(self.user.id, coll.id)
|
|
if names_q is None
|
|
else [
|
|
r
|
|
for r in await provider.resources(self.user.id, coll.id)
|
|
if r.name in set(names_q)
|
|
]
|
|
)
|
|
):
|
|
add(res)
|
|
return self.multistatus(ms)
|
|
|
|
# sync-collection (RFC 6578)
|
|
since = _token_number(rep.sync_token)
|
|
if rep.sync_token and since is None:
|
|
raise DavError(403, condition=q(D, "valid-sync-token"))
|
|
if since is None:
|
|
changed = None
|
|
deleted: list[str] = []
|
|
token = coll.sync_token
|
|
else:
|
|
changes = await provider.changes(self.user.id, coll.id, since)
|
|
if changes is None:
|
|
raise DavError(403, condition=q(D, "valid-sync-token"))
|
|
changed, deleted, token = changes.changed, changes.deleted, changes.sync_token
|
|
if changed is None:
|
|
resources = (
|
|
await provider.get(self.user.id, coll.id, None)
|
|
if wants_data
|
|
else await provider.resources(self.user.id, coll.id)
|
|
)
|
|
elif changed:
|
|
resources = (
|
|
await provider.get(self.user.id, coll.id, changed)
|
|
if wants_data
|
|
else [
|
|
r
|
|
for r in await provider.resources(self.user.id, coll.id)
|
|
if r.name in set(changed)
|
|
]
|
|
)
|
|
else:
|
|
resources = []
|
|
present = {r.name for r in resources}
|
|
for res in resources:
|
|
add(res)
|
|
for name in deleted:
|
|
if name not in present:
|
|
ms.response(self.res_href(segment, coll.id, name), {}, status=404)
|
|
ms.sync_token(f"{SYNC_PREFIX}{token}")
|
|
return self.multistatus(ms)
|
|
|
|
# -- resources ---------------------------------------------------------------------
|
|
|
|
async def resource_req(
|
|
self, method: str, segment: str, provider: DavProvider, coll_id: str, name: str
|
|
) -> Response:
|
|
coll = await self.find(provider, coll_id)
|
|
if method in ("GET", "HEAD"):
|
|
got = await provider.get(self.user.id, coll.id, [name])
|
|
if not got:
|
|
raise DavError(404)
|
|
res = got[0]
|
|
content = (res.data or "").encode() if method == "GET" else b""
|
|
return Response(
|
|
status_code=200,
|
|
content=content,
|
|
media_type=provider.content_type,
|
|
headers={"ETag": f'"{res.etag}"'},
|
|
)
|
|
if method == "PROPFIND":
|
|
got = await provider.get(self.user.id, coll.id, [name])
|
|
if not got:
|
|
raise DavError(404)
|
|
return await self.respond_props(
|
|
[
|
|
(
|
|
self.res_href(segment, coll.id, name),
|
|
self.resource_props(provider, got[0], True),
|
|
)
|
|
],
|
|
await self.body(),
|
|
)
|
|
if method == "PUT":
|
|
if coll.read_only:
|
|
raise DavError(403, condition=q(D, "need-privileges"))
|
|
data = (await self.request.body()).decode("utf-8", "replace")
|
|
etag, created = await provider.put(
|
|
self.user.id,
|
|
coll.id,
|
|
name,
|
|
data,
|
|
if_match=_etag_header(self.request.headers.get("if-match")),
|
|
if_none_match=(self.request.headers.get("if-none-match") or "").strip() == "*",
|
|
)
|
|
return Response(status_code=201 if created else 204, headers={"ETag": f'"{etag}"'})
|
|
if method == "DELETE":
|
|
if coll.read_only:
|
|
raise DavError(403)
|
|
await provider.delete(
|
|
self.user.id,
|
|
coll.id,
|
|
name,
|
|
if_match=_etag_header(self.request.headers.get("if-match")),
|
|
)
|
|
return Response(status_code=204)
|
|
return Response(status_code=405)
|
|
|
|
|
|
def _last_segment(h: str) -> str:
|
|
path = urlsplit(h).path
|
|
return unquote(path.rstrip("/").rsplit("/", 1)[-1])
|
|
|
|
|
|
def _token_number(token: str) -> int | None:
|
|
if not token:
|
|
return None
|
|
tail = token.rsplit("/", 1)[-1]
|
|
return int(tail) if tail.isdigit() else None
|
|
|
|
|
|
def _etag_header(value: str | None) -> str | None:
|
|
if not value:
|
|
return None
|
|
value = value.strip()
|
|
if value == "*":
|
|
return "*"
|
|
if value.startswith("W/"):
|
|
value = value[2:]
|
|
return value.strip('"')
|
|
|
|
|
|
__all__ = ["router", "uuid"]
|