Files
caldav/sidecar/collections.ts
T
pastilhasandClaude Opus 5 785e5f9eed edit and delete, on both screens
the screens could show a calendar and an address book and change neither. this
adds a pencil and a bin to every event and every card, with a confirm on the bin.

three things worth defending.

THE HREF IS THE ADDRESS, and the listing now carries it. it used to return only
the object's data, which is enough to show something and not enough to change
it. deriving `<uid>.ics` was the obvious shortcut and is wrong: a .ics filename
only conventionally matches the uid — radicale stores what the writing client
chose — so it works for everything officer wrote and misses everything a phone
did. proved immediately: this machine's own calendar holds
62970EC7-47B1-4214-858E-85C32C2476A5.ics, written by an iphone.

READ, PATCH, PUT — NEVER REBUILD. an event is a file with far more in it than
these screens show: alarms, attendees, RRULEs, every X- property. rebuilding it
from the fields on screen would silently drop all of that, and an edit that
quietly deletes half an event is worse than no editing. so a save reads the
original text, replaces only the lines it owns, and puts the whole file back.
verified on a real event: 22 lines before, 22 after, one changed.

THE EDITOR IS DELIBERATELY SMALL. title and place for an event, name/email/phone
for a card. times and recurrence are where an editor does damage, and a
half-editor that rewrites an RRULE it does not understand is the thing this
avoids. `setProperty` also escapes commas, semicolons and newlines, because a
raw comma silently splits an iCalendar TEXT value into parts.

the sidecar grew GET/PUT/DELETE on /_officer/item, above the `collection is
required` guard — they take an href rather than a collection, and sitting below
it they answered 400 forever. both check the href is inside this user's own tree
even though X-Remote-User already scopes radicale: an href from a request body
is attacker-shaped input either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 10:25:38 +00:00

153 lines
7.2 KiB
TypeScript

import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical';
// The JSON door: what Officer's own web UI reads.
//
// This talks DAV to Radicale over loopback rather than reading Radicale's on-disk format. The storage
// layout is Radicale's private business and changes between versions; PROPFIND is its supported
// interface and costs one in-process HTTP hop. Parsing the storage directly would be faster and would
// break silently on an upgrade, which is a bad trade for a personal calendar.
//
// The browser never sees any of this XML — that is the entire point of having a JSON door. See
// docs/nextcloud-replacement.md, "two front doors, not one".
export type Collection = {
/** DAV path, relative to the /dav mount — the id the UI passes back. */
path: string;
displayName: string;
kind: 'calendar' | 'addressbook';
/** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */
color?: string;
};
type Fetcher = (path: string, init: RequestInit) => Promise<Response>;
// Deliberately not a real XML parser. The only documents read here are Radicale's own multistatus
// responses — a narrow, machine-generated shape, not arbitrary user XML — and adding a parser
// dependency to pull four fields out of it is not worth the weight. If this ever needs to read XML
// from somewhere less predictable, it should grow a parser rather than more regexes.
const between = (xml: string, tag: string): string[] => {
const out: string[] = [];
const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tag}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${tag}>`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(xml))) out.push(m[1] ?? '');
return out;
};
const first = (xml: string, tag: string): string | undefined => between(xml, tag)[0];
const decodeEntities = (s: string) =>
s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
const PROPFIND_COLLECTIONS = `<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:IC="http://apple.com/ns/ical/">
<D:prop><D:resourcetype/><D:displayname/><IC:calendar-color/></D:prop>
</D:propfind>`;
/** Every calendar and address book belonging to this user. */
export async function listCollections(fetcher: Fetcher, userId: string): Promise<Collection[]> {
const res = await fetcher(`/${userId}/`, {
method: 'PROPFIND',
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: PROPFIND_COLLECTIONS,
});
if (!res.ok && res.status !== 207) return [];
const xml = await res.text();
const collections: Collection[] = [];
for (const response of between(xml, 'response')) {
const href = first(response, 'href');
if (!href) continue;
// The tag must END here — `\b` alone also matches `<C:calendar-home-set/>`, which the principal
// carries, and that made the principal itself show up in the list as a calendar named "1".
const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\s*\/>/.test(response);
const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\s*\/>/.test(response);
if (!isCalendar && !isAddressbook) continue; // the principal itself, or a plain collection
const path = decodeEntities(href.trim());
const displayName = decodeEntities((first(response, 'displayname') ?? '').trim());
const color = (first(response, 'calendar-color') ?? '').trim() || undefined;
const lastSegment = path.replace(/\/$/, '').split('/').pop() || 'Untitled';
collections.push({
path,
// Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a
// name. A real client sets something human when it creates a collection; until one does, the last
// segment is the better label — and it is what every DAV client falls back to anyway.
displayName: displayName && !displayName.includes('/') ? displayName : lastSegment,
kind: isCalendar ? 'calendar' : 'addressbook',
color,
});
}
return collections;
}
/**
* Every object in one collection, in one round trip.
*
* REPORT, not PROPFIND. `calendar-data` and `address-data` are not WebDAV live properties — RFC 4791 and
* RFC 6352 define them as report-only, and Radicale correctly returns an empty prop for them under
* PROPFIND. Asking the wrong way produces a 207 full of nothing, which reads exactly like "the calendar
* is empty" and is a genuinely confusing way to be wrong.
*
* One report rather than a listing plus a GET per item: a calendar with a thousand events would
* otherwise be a thousand round trips.
*/
async function readObjects(
fetcher: Fetcher,
path: string,
kind: 'calendar' | 'addressbook',
): Promise<{ href: string; data: string }[]> {
const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data';
const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav';
const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query';
// An empty filter means "everything". A calendar-query with no comp-filter is legal and is the
// cheapest way to say it.
const filter = kind === 'calendar' ? '<X:filter><X:comp-filter name="VCALENDAR"/></X:filter>' : '<X:filter/>';
const res = await fetcher(path, {
method: 'REPORT',
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: `<?xml version="1.0"?><X:${queryTag} xmlns:D="DAV:" xmlns:X="${ns}"><D:prop><X:${dataTag}/></D:prop>${filter}</X:${queryTag}>`,
});
if (!res.ok && res.status !== 207) return [];
const xml = await res.text();
/*
Paired with the HREF, because a UID is not an address.
Reading returned only the object's data, which is enough to SHOW something and not enough to change
it: DELETE and PUT both need the item's URL, and a `.ics` filename is only conventionally the UID —
Radicale stores what the client that wrote it chose. Deriving `<uid>.ics` works for everything Officer
wrote and fails for everything a phone did, which is the worst kind of nearly-right.
So each `<response>` is split first and the href taken from it, rather than pulling every
`calendar-data` out of the whole document and losing which response each came from.
*/
return between(xml, 'response')
.map((block) => {
const href = decodeEntities(first(block, 'href') ?? '').trim();
const data = decodeEntities(first(block, dataTag) ?? '').trim();
return { href, data };
})
.filter((o) => o.href && o.data);
}
export async function listEvents(fetcher: Fetcher, path: string): Promise<CalendarEvent[]> {
const objects = await readObjects(fetcher, path, 'calendar');
// One .ics can hold several VEVENTs (a recurring series with overrides), and they share its href —
// which is correct: deleting any of them deletes the file they live in.
return objects.flatMap((o) => parseEvents(o.data).map((e) => ({ ...e, href: o.href })));
}
export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
const objects = await readObjects(fetcher, path, 'addressbook');
return objects.flatMap((o) => parseContacts(o.data).map((c) => ({ ...c, href: o.href })));
}