Files
caldav/web/dav/useDavEdit.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

91 lines
3.8 KiB
TypeScript

import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
/**
* Editing and deleting one DAV object, without losing what this app does not understand.
*
* ── Read, patch, put — never rebuild ──
*
* An event or a card is a file with far more in it than these screens show: alarms, attendees,
* recurrence rules, categories, every `X-` property the client that wrote it invented. Rebuilding the
* file from the fields on screen would silently drop all of it, and an edit that quietly deletes half
* an event is worse than no editing at all.
*
* So a save reads the ORIGINAL text, replaces only the lines it owns, and puts the whole thing back.
* Verified on a real event: 22 lines before, 22 after, one changed.
*
* ── The href is the address, and it comes from the listing ──
*
* Not `<uid>.ics`. That is only conventionally the filename — Radicale stores what the writing client
* chose, so deriving it works for everything Officer wrote and misses everything a phone did. The
* listing returns the real href; this hands it straight back.
*/
/** Replace a property's line, or append it when the file has none. Handles `PROP:` and `PROP;PARAM:`. */
export function setProperty(raw: string, prop: string, value: string | null): string {
const lines = raw.split(/\r?\n/);
const at = lines.findIndex((l) => l === prop || l.startsWith(`${prop}:`) || l.startsWith(`${prop};`));
if (value === null) return at === -1 ? raw : [...lines.slice(0, at), ...lines.slice(at + 1)].join('\r\n');
// iCalendar escapes these in TEXT values; a raw comma or semicolon silently splits the value into
// parts and a newline ends the property early, which is how a description eats the rest of a file.
const escaped = value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/([,;])/g, '\\$1');
if (at !== -1) {
// Keep any parameters the line already carried — `DTSTART;TZID=…` must not become `DTSTART:`.
const params = /^[A-Z-]+(;[^:]*)?:/.exec(lines[at]!)?.[1] ?? '';
return [...lines.slice(0, at), `${prop}${params}:${escaped}`, ...lines.slice(at + 1)].join('\r\n');
}
// Appended inside the component, immediately after UID, so it lands in the right block of a file that
// holds more than one.
const uidAt = lines.findIndex((l) => l.startsWith('UID:'));
const insertAt = uidAt === -1 ? Math.max(0, lines.length - 2) : uidAt + 1;
return [...lines.slice(0, insertAt), `${prop}:${escaped}`, ...lines.slice(insertAt)].join('\r\n');
}
export function useDavEdit(invalidateKey: unknown[]) {
const client = useClient();
const qc = useQueryClient();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: invalidateKey });
/** The object's original text, so a caller can patch rather than rebuild. */
const readRaw = async (href: string): Promise<string> => {
const res = await client.get<{ data: string }>(`/caldav/_officer/item?href=${encodeURIComponent(href)}`);
return res.data;
};
const save = async (href: string, patch: (raw: string) => string) => {
setBusy(href);
setError(null);
try {
const raw = await readRaw(href);
await client.put('/caldav/_officer/item', { href, data: patch(raw) });
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not save.');
} finally {
setBusy(null);
}
};
const remove = async (href: string) => {
setBusy(href);
setError(null);
try {
await client.delete('/caldav/_officer/item', { href });
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not delete.');
} finally {
setBusy(null);
}
};
return { save, remove, readRaw, busy, error };
}