App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS
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>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""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)."""
|
||||
...
|
||||
Reference in New Issue
Block a user