import type { ReactElement } from "react"; import type { AppContext } from "@/worker/types"; export type DavListingKind = | "collection" | "file" | "calendar" | "addressbook" | "calendar-object" | "address-object" | "principal"; export interface DavListingEntry { href: string; name: string; kind: DavListingKind; description?: string | null; size?: number | null; modifiedAtMs?: number | null; } export interface DavListingInput { title: string; href: string; entries: DavListingEntry[]; parentHref?: string | null; } const KIND_LABEL: Record = { collection: "Folder", file: "File", calendar: "Calendar", addressbook: "Address book", "calendar-object": "Calendar object", "address-object": "Address object", principal: "Principal", }; const KIND_ORDER: Record = { collection: 0, calendar: 1, addressbook: 1, principal: 1, file: 2, "calendar-object": 2, "address-object": 2, }; const LISTING_STYLE = ` :root { color-scheme: dark; --canvas: #221f21; --zinc-50: #fafaf9; --zinc-100: #f5f4f4; --zinc-200: #e5e4e5; --zinc-300: #d6d4d7; --zinc-400: #a3a1a8; --zinc-500: #747178; --zinc-800: #2a2729; --zinc-900: #1b181a; --accent-400: #f06da6; --accent-500: #db3a8b; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background: var(--canvas); color: var(--zinc-100); } body { margin: 0; min-height: 100vh; background: var(--canvas); font-weight: 450; -webkit-font-smoothing: antialiased; } .container { max-width: 80rem; margin: 0 auto; padding: 32px 16px; } .path { margin-bottom: 16px; max-width: 42rem; color: var(--zinc-400); font-family: monospace; font-size: 0.875rem; overflow-wrap: anywhere; } .path-prefix { color: var(--zinc-500); } .table-wrap { overflow: hidden; border: 1px solid rgb(66 63 66 / 0.72); } table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } thead { background: rgb(27 24 26 / 0.6); } th { padding: 8px 16px; color: var(--zinc-500); font-size: 0.75rem; text-transform: uppercase; font-weight: 650; letter-spacing: 0.08em; text-align: left; } td { padding: 10px 16px; border-top: 1px solid rgb(66 63 66 / 0.72); color: var(--zinc-400); vertical-align: top; } tbody tr { transition: background-color 75ms; } tbody tr:hover { background: rgb(42 39 41 / 0.65); } a { color: var(--zinc-200); text-decoration: none; font-weight: 650; } a:hover { color: var(--accent-400); } .muted { margin-top: 4px; color: var(--zinc-500); font-size: 0.8125rem; font-weight: 450; } .kind { color: var(--zinc-300); } .empty { color: var(--zinc-500); text-align: center; padding: 48px 24px; } ::selection { background: rgb(219 58 139 / 0.28); color: #fdf2f8; } @media (min-width: 640px) { .container { padding-left: 24px; padding-right: 24px; } } @media (max-width: 680px) { th:nth-child(3), td:nth-child(3), th:nth-child(4), td:nth-child(4) { display: none; } } `; function formatBytes(bytes: number | null | undefined): string { if (bytes == null) return ""; if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; let value = bytes / 1024; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; } function formatDate(ms: number | null | undefined): string { return ms == null ? "" : new Date(ms).toISOString().replace(".000Z", "Z"); } function sortEntries(entries: DavListingEntry[]): DavListingEntry[] { return [...entries].sort((a, b) => { const kind = KIND_ORDER[a.kind] - KIND_ORDER[b.kind]; if (kind !== 0) return kind; return a.name.localeCompare(b.name, "en", { sensitivity: "base" }); }); } function tableRow(entry: DavListingEntry, index: number): ReactElement { return ( {entry.name} {entry.description ?
{entry.description}
: null} {KIND_LABEL[entry.kind]} {formatBytes(entry.size)} {formatDate(entry.modifiedAtMs)} ); } function emptyRow(entries: DavListingEntry[]): ReactElement | null { if (entries.length > 0) return null; return ( This collection is empty. ); } function parentRow(parentHref: string | null | undefined): ReactElement | null { if (!parentHref) return null; return ( Parent directory Folder ); } function ListingPage(input: DavListingInput): ReactElement { const entries = sortEntries(input.entries); return ( {input.title}
dab {input.href}
{parentRow(input.parentHref)} {entries.map(tableRow)} {emptyRow(entries)}
Name Kind Size Modified
); } export async function davHtmlListingResponse(c: AppContext, input: DavListingInput, method: string): Promise { c.header("Cache-Control", "no-store"); c.header( "Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", ); c.header("X-Content-Type-Options", "nosniff"); const response = await c.render(); if (method !== "HEAD") return response; return new Response(null, { status: response.status, headers: response.headers }); }