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>
119 lines
3.9 KiB
Python
119 lines
3.9 KiB
Python
"""What a plugin implements to serve its collections over DAV (SPEC App. E.1).
|
|
|
|
The core (app/dav/routes.py) speaks the protocol; a provider only knows its
|
|
own data: collections (calendars, address books) and the resources in them
|
|
(one iCalendar object / one vCard each), named, with an ETag. Every method
|
|
gets the signed-in user's id; providers open the user's RLS session
|
|
(``sdk(name).session(user_id)``) themselves.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Literal, Protocol
|
|
|
|
|
|
class DavError(Exception):
|
|
"""A request the provider refuses: ``status`` is the HTTP answer
|
|
(403 read-only, 404 missing, 409 conflict, 412 precondition failed, 415
|
|
unsupported data); ``condition`` an optional DAV precondition element
|
|
(e.g. ``{urn:ietf:params:xml:ns:caldav}valid-calendar-data``)."""
|
|
|
|
def __init__(self, status: int, message: str = "", condition: str | None = None) -> None:
|
|
super().__init__(message or str(status))
|
|
self.status = status
|
|
self.condition = condition
|
|
|
|
|
|
@dataclass
|
|
class Collection:
|
|
id: str
|
|
displayname: str
|
|
sync_token: int # the latest change number; also the CTag
|
|
description: str = ""
|
|
color: str | None = None # "#rrggbb"
|
|
read_only: bool = False
|
|
components: list[str] = field(default_factory=lambda: ["VEVENT"]) # calendars only
|
|
|
|
|
|
@dataclass
|
|
class Resource:
|
|
name: str # last path segment, e.g. "abc.ics"
|
|
etag: str # without quotes
|
|
data: str | None = None # the iCalendar / vCard text, when asked for
|
|
|
|
|
|
@dataclass
|
|
class Changes:
|
|
sync_token: int
|
|
changed: list[str] # resource names changed or added since the old token
|
|
deleted: list[str] # resource names gone since the old token
|
|
|
|
|
|
class DavProvider(Protocol):
|
|
kind: Literal["calendar", "addressbook"]
|
|
content_type: str # "text/calendar; charset=utf-8" / "text/vcard; charset=utf-8"
|
|
extension: str # ".ics" / ".vcf"
|
|
|
|
async def collections(self, user_id: uuid.UUID) -> list[Collection]: ...
|
|
|
|
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]: ...
|
|
|
|
async def get(
|
|
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
|
|
) -> list[Resource]: ...
|
|
|
|
async def put(
|
|
self,
|
|
user_id: uuid.UUID,
|
|
collection_id: str,
|
|
name: str,
|
|
data: str,
|
|
*,
|
|
if_match: str | None,
|
|
if_none_match: bool,
|
|
) -> tuple[str, bool]:
|
|
"""Store ``data`` as ``name``; returns (new ETag, created)."""
|
|
...
|
|
|
|
async def delete(
|
|
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
|
|
) -> None: ...
|
|
|
|
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
|
|
"""What changed after change number ``since``; None when that token is
|
|
too old to answer (the client then resyncs from scratch)."""
|
|
...
|
|
|
|
async def create_collection(
|
|
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
|
|
) -> None:
|
|
"""Create a collection at the id the client chose (MKCALENDAR / MKCOL);
|
|
DavError(403) when that id cannot be used."""
|
|
...
|
|
|
|
async def update_collection(
|
|
self,
|
|
user_id: uuid.UUID,
|
|
collection_id: str,
|
|
*,
|
|
displayname: str | None = None,
|
|
color: str | None = None,
|
|
description: str | None = None,
|
|
) -> None: ...
|
|
|
|
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None: ...
|
|
|
|
async def query(
|
|
self,
|
|
user_id: uuid.UUID,
|
|
collection_id: str,
|
|
start: datetime | None,
|
|
end: datetime | None,
|
|
) -> list[str] | None:
|
|
"""Calendars: names of resources with an occurrence overlapping the
|
|
range; None when the provider cannot filter (the core then returns all)."""
|
|
...
|