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
This commit is contained in:
+30
-8
@@ -99,7 +99,11 @@ export async function listCollections(fetcher: Fetcher, userId: string): Promise
|
||||
* 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<string[]> {
|
||||
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';
|
||||
@@ -114,17 +118,35 @@ async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'a
|
||||
});
|
||||
if (!res.ok && res.status !== 207) return [];
|
||||
const xml = await res.text();
|
||||
return between(xml, dataTag)
|
||||
.map((chunk) => decodeEntities(chunk).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/*
|
||||
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 blobs = await readObjects(fetcher, path, 'calendar');
|
||||
return blobs.flatMap(parseEvents);
|
||||
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 blobs = await readObjects(fetcher, path, 'addressbook');
|
||||
return blobs.flatMap(parseContacts);
|
||||
const objects = await readObjects(fetcher, path, 'addressbook');
|
||||
return objects.flatMap((o) => parseContacts(o.data).map((c) => ({ ...c, href: o.href })));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user