diff --git a/FEATURES.md b/FEATURES.md index 05608b3..c36ca83 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -529,6 +529,34 @@ nothing for anybody else. - **iCal import** through `CalendarEvent/parse` (a file of any number of events), from the calendar's own menu, into that calendar. The events are filed rather than scheduled: no invitations go out to anyone named in them. +- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list. + Added in Settings › Calendar & contacts, read-only, and shown beside your own + with their own colour. + + **Nothing is stored.** The document is fetched when you open the calendar and + parsed in the browser; the server keeps no copy, no cache and no schedule, + which is what lets an immutable container serve this at all. There is no + timer either: ihasmail has nowhere to run one, so the honest guarantee is + that a subscription is as current as the last time somebody looked — which is + also when it matters. + + The fetch has to happen on the server, because a calendar URL belongs to + whoever published it and almost none of them send CORS headers. That makes it + the second place ihasmail reaches an address a stranger chose, and it goes + through **exactly the same guard as the image proxy** — one implementation, + not two: the name is resolved and every answer must be acceptable, the + connection is pinned to the address that was checked, and each redirect is + re-resolved and re-pinned. `webcal:` is understood, because that is how these + are published, and it is read as `https:` rather than waved past the checks. + + Two consequences worth stating. A calendar on a private address — including + one on your own machine — is refused, by design. And **recurring events are + not expanded**: `RRULE` is a small language with a lot of edge cases, and a + subscription quietly showing the wrong dates would be worse than one showing + the first occurrence. + + A subscription that cannot be read **says so** in the sidebar rather than + drawing an empty calendar, which looks like a calendar with nothing in it. - **Birthdays**, as a calendar of its own derived from the birthdays already on your contacts. Off until switched on in Settings › Calendar & contacts, and hideable from the calendar's own sidebar without turning it off. diff --git a/server/src/app.ts b/server/src/app.ts index 53dfeae..f07a894 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -30,6 +30,7 @@ import { revokeAppPassword, } from "./account.js"; import { imageProxyHandler } from "./imageproxy.js"; +import { icsProxyHandler } from "./icsproxy.js"; import { staticHandler } from "./static.js"; type Env = { Variables: { session: LiveSession } }; @@ -613,6 +614,9 @@ export function createApp(basePath = config.basePath): Hono { // ---------- Remote image privacy proxy ---------- api.get("/image", requireSession, imageProxyHandler); +// Behind the session for the same reason the image proxy is: an open fetcher +// on someone else's server is a gift to whoever finds it. +api.get("/ics", requireSession, icsProxyHandler); api.notFound((c) => c.json({ error: "not_found" }, 404)); api.onError((err, c) => { diff --git a/server/src/icsproxy.test.ts b/server/src/icsproxy.test.ts new file mode 100644 index 0000000..45829d2 --- /dev/null +++ b/server/src/icsproxy.test.ts @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +process.env.STALWART_URL = "http://127.0.0.1:1"; +process.env.APP_SECRET = "test-secret-for-ics-proxy"; + +const { safeFetch, safeFetchStatus } = await import("./imageproxy.js"); + +/** + * Subscribing to a calendar makes the server fetch a URL a stranger published, + * which is the second time this app knocks on a door somebody else chose. It + * goes through the same guard as the first — these tests are about that guard + * being reached, and about `webcal:` not being a way around it. + */ + +test("a calendar URL is refused before any connection when it points somewhere private", async () => { + for (const url of [ + "http://127.0.0.1/calendar.ics", + "http://169.254.169.254/latest/meta-data/", // cloud metadata + "http://[::1]/calendar.ics", + "http://10.0.0.1/c.ics", + "https://192.168.1.1/c.ics", + ]) { + const got = await safeFetch(url, 500); + assert.equal(got, "forbidden_target", url); + } +}); + +test("webcal: is treated as https rather than waved through", async () => { + // Every subscription URL people are given is a webcal: one. It has to be + // understood, and it must not be a way past the address check. + const got = await safeFetch("webcal://127.0.0.1/calendar.ics", 500); + assert.equal(got, "forbidden_target"); +}); + +test("schemes that are not http, https or webcal are refused", async () => { + for (const url of ["file:///etc/passwd", "ftp://example.com/c.ics", "gopher://example.com", "data:text/calendar,BEGIN:VCALENDAR"]) { + const got = await safeFetch(url, 500); + assert.equal(got, "bad_scheme", url); + } +}); + +test("a URL carrying credentials is refused", async () => { + // Credentials in a subscription URL would be sent by the server on the + // reader's behalf to a host the reader may not have looked at. + assert.equal(await safeFetch("http://user:pass@example.com/c.ics", 500), "bad_url"); +}); + +test("nonsense is refused rather than guessed at", async () => { + for (const url of ["", "not a url", "://missing-scheme"]) { + assert.equal(await safeFetch(url, 500), "bad_url", JSON.stringify(url)); + } +}); + +test("each refusal has a status that says which kind it was", () => { + assert.equal(safeFetchStatus("forbidden_target"), 403); + assert.equal(safeFetchStatus("bad_scheme"), 400); + assert.equal(safeFetchStatus("bad_url"), 400); + assert.equal(safeFetchStatus("bad_redirect"), 400); + assert.equal(safeFetchStatus("dns_failure"), 502); + assert.equal(safeFetchStatus("fetch_failed"), 502); +}); diff --git a/server/src/icsproxy.ts b/server/src/icsproxy.ts new file mode 100644 index 0000000..30f3c96 --- /dev/null +++ b/server/src/icsproxy.ts @@ -0,0 +1,85 @@ +import type { Context } from "hono"; +import { safeFetch, safeFetchStatus } from "./imageproxy.js"; + +/** + * Fetching a calendar somebody has subscribed to. + * + * The browser cannot do this itself: a calendar URL belongs to whoever + * published it and almost none of them send CORS headers, so the request has + * to be made from here. That makes it the second place ihasmail reaches out to + * an address a stranger chose, and it goes through exactly the same guard as + * the first — `safeFetch` resolves the name, refuses private space on every + * answer, pins the connection to the address it checked, and re-checks each + * redirect. There is deliberately no second implementation of that. + * + * **Nothing is stored.** The text goes straight back to the browser, which + * parses it and holds the result in memory for as long as the tab is open. The + * server keeps no copy, no cache and no schedule, which is what lets an + * immutable container serve this at all. + */ + +/** Generous for a calendar, small enough that nobody can post a film through it. */ +const MAX_ICS_BYTES = 4 * 1024 * 1024; + +/** + * Types a calendar is served as in practice. `text/plain` and the octet-stream + * are here because a great many servers get this wrong, and refusing a real + * calendar over a header the publisher chose badly helps nobody -- the parser + * checks the content itself, which is the claim that actually matters. + */ +const ACCEPTABLE = new Set(["text/calendar", "text/plain", "application/octet-stream", "application/ics", ""]); + +export async function icsProxyHandler(c: Context) { + const got = await safeFetch(c.req.query("url") ?? "", 20_000); + if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400); + const { res, done } = got; + + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + done(); + res.resume(); + return c.json({ error: "fetch_failed", status: res.statusCode ?? 0 }, 502); + } + const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase(); + if (!ACCEPTABLE.has(type)) { + done(); + res.resume(); + return c.json({ error: "not_calendar", type }, 415); + } + const declared = Number(res.headers["content-length"] ?? "0"); + if (declared > MAX_ICS_BYTES) { + done(); + res.resume(); + return c.json({ error: "too_large" }, 413); + } + + // Read it here rather than streaming: the browser needs the whole document + // to parse it, and the cap has to hold whether or not a length was declared. + let total = 0; + const chunks: Buffer[] = []; + try { + await new Promise((resolve, reject) => { + res.on("data", (chunk: Buffer) => { + total += chunk.byteLength; + if (total > MAX_ICS_BYTES) { + res.destroy(); + reject(new Error("too_large")); + return; + } + chunks.push(chunk); + }); + res.on("end", () => resolve()); + res.on("error", reject); + }); + } catch (err) { + done(); + return c.json({ error: (err as Error).message === "too_large" ? "too_large" : "fetch_failed" }, 502); + } + done(); + + return c.body(Buffer.concat(chunks).toString("utf8"), 200, { + "Content-Type": "text/calendar; charset=utf-8", + // Never stored on disk, and never held by anything in between either. + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); +} diff --git a/server/src/imageproxy.ts b/server/src/imageproxy.ts index ddba4e2..6c8d9fb 100644 --- a/server/src/imageproxy.ts +++ b/server/src/imageproxy.ts @@ -98,32 +98,50 @@ export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promi * Gmail-style remote content proxy: hides the reader's IP address and * user-agent from tracking pixels, and blocks SSRF to internal networks. */ -export async function imageProxyHandler(c: Context) { - if (!config.imageProxy) return c.json({ error: "disabled" }, 404); - const raw = c.req.query("url") ?? ""; +/** Why a guarded fetch refused, in the words the handlers answer with. */ +export type SafeFetchError = "bad_url" | "bad_scheme" | "forbidden_target" | "dns_failure" | "fetch_failed" | "bad_redirect"; + +export interface SafeFetchResult { + res: IncomingMessage; + /** The URL actually fetched, which is not the one asked for if it redirected. */ + url: URL; + done: () => void; +} + +/** + * Fetch a URL nobody here chose, with every check the image proxy has always + * made — and made in one place, because a second copy of an SSRF guard is how + * one of them ends up missing a case. + * + * The name is resolved first and *every* answer has to be acceptable, the + * connection is pinned to the address that was checked, and each redirect hop + * is re-resolved and re-pinned rather than handed to the socket library. + */ +export async function safeFetch(raw: string, timeoutMs = 15_000): Promise { let url: URL; try { url = new URL(raw); } catch { - return c.json({ error: "bad_url" }, 400); + return "bad_url"; } - if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400); - if (url.username || url.password) return c.json({ error: "bad_url" }, 400); + // webcal: is an http URL wearing a different word; nothing else is allowed. + if (url.protocol === "webcal:") url = new URL(`https:${raw.slice(raw.indexOf(":") + 1)}`); + if (url.protocol !== "http:" && url.protocol !== "https:") return "bad_scheme"; + if (url.username || url.password) return "bad_url"; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 15_000); - let res: IncomingMessage; + const timer = setTimeout(() => controller.abort(), timeoutMs); + const done = () => clearTimeout(timer); try { let addr: string; try { addr = await resolveAllowed(url.hostname); } catch (err) { - clearTimeout(timer); - return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502); + done(); + return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure"; } - res = await fetchPinned(url, addr, controller.signal); + let res = await fetchPinned(url, addr, controller.signal); - // Follow a limited number of redirects, re-checking and re-pinning each hop. let hops = 0; while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) { const loc = res.headers.location; @@ -131,38 +149,58 @@ export async function imageProxyHandler(c: Context) { res.resume(); // discard the redirect body const next = new URL(loc, url); if (next.protocol !== "http:" && next.protocol !== "https:") { - clearTimeout(timer); - return c.json({ error: "bad_redirect" }, 400); + done(); + return "bad_redirect"; } try { addr = await resolveAllowed(next.hostname); } catch (err) { - clearTimeout(timer); - return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502); + done(); + return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure"; } url = next; res = await fetchPinned(url, addr, controller.signal); hops++; } + return { res, url, done }; } catch { - clearTimeout(timer); - return c.json({ error: "fetch_failed" }, 502); + done(); + return "fetch_failed"; } +} +const SAFE_FETCH_STATUS: Record = { + bad_url: 400, + bad_scheme: 400, + bad_redirect: 400, + forbidden_target: 403, + dns_failure: 502, + fetch_failed: 502, +}; + +export function safeFetchStatus(err: SafeFetchError): number { + return SAFE_FETCH_STATUS[err]; +} + +export async function imageProxyHandler(c: Context) { + if (!config.imageProxy) return c.json({ error: "disabled" }, 404); + const got = await safeFetch(c.req.query("url") ?? ""); + if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400); + const { res, done } = got; if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { - clearTimeout(timer); + done(); res.resume(); return c.json({ error: "fetch_failed" }, 502); } const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase(); if (!type.startsWith("image/") || type === "image/svg+xml") { - clearTimeout(timer); + done(); res.resume(); return c.json({ error: "not_image" }, 415); } const len = Number(res.headers["content-length"] ?? "0"); if (len > MAX_IMAGE_BYTES) { - clearTimeout(timer); + done(); res.resume(); return c.json({ error: "too_large" }, 413); } @@ -176,7 +214,7 @@ export async function imageProxyHandler(c: Context) { else controller2.enqueue(chunk); }, }); - res.on("close", () => clearTimeout(timer)); + res.on("close", done); const headers = new Headers({ "Content-Type": type, "Cache-Control": "private, max-age=86400", diff --git a/web/src/lib/__tests__/ics.test.ts b/web/src/lib/__tests__/ics.test.ts new file mode 100644 index 0000000..7ce00f8 --- /dev/null +++ b/web/src/lib/__tests__/ics.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics"; + +const cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`; +const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`; +const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + +describe("unfold", () => { + it("joins a continuation with nothing between, per the RFC", () => { + expect(unfold("SUMMARY:A very\r\n long title")).toEqual(["SUMMARY:A very long title"]); + expect(unfold("SUMMARY:A\r\n\tB")).toEqual(["SUMMARY:AB"]); + }); + + it("handles all three line endings", () => { + expect(unfold("A\r\nB\nC\rD")).toEqual(["A", "B", "C", "D"]); + }); + + it("does not treat a leading space on the first line as a continuation", () => { + expect(unfold(" oops")).toEqual([" oops"]); + }); +}); + +describe("parseLine", () => { + it("splits a plain property", () => { + expect(parseLine("SUMMARY:Standup")).toEqual({ name: "SUMMARY", params: {}, value: "Standup" }); + }); + + it("reads parameters", () => { + expect(parseLine("DTSTART;VALUE=DATE:20260904")).toEqual({ + name: "DTSTART", + params: { VALUE: "DATE" }, + value: "20260904", + }); + }); + + it("ignores a colon inside a quoted parameter, which is a real shape", () => { + // A naive indexOf(":") reads this as a property called DTSTART;TZID="GMT+01 + const line = parseLine('DTSTART;TZID="GMT+01:00":20260904T140000'); + expect(line?.name).toBe("DTSTART"); + expect(line?.value).toBe("20260904T140000"); + expect(line?.params.TZID).toBe("GMT+01:00"); + }); + + it("uppercases the name, since the RFC does not require any particular case", () => { + expect(parseLine("summary:x")?.name).toBe("SUMMARY"); + }); + + it("says nothing about a line with no colon", () => { + expect(parseLine("NONSENSE")).toBeNull(); + expect(parseLine("")).toBeNull(); + }); +}); + +describe("unescapeText", () => { + it("undoes the four escapes and leaves everything else", () => { + expect(unescapeText("a\\nb")).toBe("a\nb"); + expect(unescapeText("a\\Nb")).toBe("a\nb"); + expect(unescapeText("a\\,b\\;c")).toBe("a,b;c"); + expect(unescapeText("a\\\\b")).toBe("a\\b"); + expect(unescapeText("100% \\real")).toBe("100% \\real"); + }); +}); + +describe("parseDateValue", () => { + it("reads a date as all-day in local time, not UTC midnight", () => { + // UTC midnight lands on the day before for anyone west of Greenwich. + const out = parseDateValue("20260904"); + expect(out?.allDay).toBe(true); + expect(ymd(out!.date)).toBe("2026-09-04"); + expect(hhmm(out!.date)).toBe("00:00"); + }); + + it("respects VALUE=DATE even on a longer string", () => { + expect(parseDateValue("20260904", { VALUE: "DATE" })?.allDay).toBe(true); + }); + + it("reads a UTC instant", () => { + const out = parseDateValue("20260904T140000Z"); + expect(out?.allDay).toBe(false); + expect(out?.date.toISOString()).toBe("2026-09-04T14:00:00.000Z"); + }); + + it("reads a floating wall clock as local time", () => { + const out = parseDateValue("20260904T140000"); + expect(out?.allDay).toBe(false); + expect(hhmm(out!.date)).toBe("14:00"); + expect(ymd(out!.date)).toBe("2026-09-04"); + }); + + it("says nothing about a value it cannot read", () => { + expect(parseDateValue("not a date")).toBeNull(); + expect(parseDateValue("")).toBeNull(); + }); +}); + +describe("parseIcsDuration", () => { + it("reads the forms a DTEND substitute uses", () => { + expect(parseIcsDuration("PT1H")).toBe(3600); + expect(parseIcsDuration("PT30M")).toBe(1800); + expect(parseIcsDuration("P1D")).toBe(86400); + expect(parseIcsDuration("P1W")).toBe(604800); + expect(parseIcsDuration("P1DT2H30M")).toBe(95400); + expect(parseIcsDuration("-PT1H")).toBe(-3600); + }); + + it("says nothing about nonsense", () => { + expect(parseIcsDuration("1 hour")).toBeNull(); + expect(parseIcsDuration("")).toBeNull(); + }); +}); + +describe("looksLikeCalendar", () => { + it("recognises a calendar and rejects an error page", () => { + expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true); + expect(looksLikeCalendar("404")).toBe(false); + }); +}); + +describe("parseIcs", () => { + it("reads a timed event with a summary and an end", () => { + const { events } = parseIcs(cal(event("UID:a@x\r\nSUMMARY:Standup\r\nDTSTART:20260904T090000Z\r\nDTEND:20260904T091500Z"))); + expect(events).toHaveLength(1); + expect(events[0]!.summary).toBe("Standup"); + expect(events[0]!.uid).toBe("a@x"); + expect(events[0]!.allDay).toBe(false); + expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(15 * 60_000); + }); + + it("reads an all-day event", () => { + const { events } = parseIcs(cal(event("UID:b@x\r\nSUMMARY:Holiday\r\nDTSTART;VALUE=DATE:20260904"))); + expect(events[0]!.allDay).toBe(true); + expect(ymd(events[0]!.start)).toBe("2026-09-04"); + expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(86400_000); + }); + + it("takes DURATION when there is no DTEND", () => { + const { events } = parseIcs(cal(event("UID:c@x\r\nDTSTART:20260904T090000Z\r\nDURATION:PT90M"))); + expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(90 * 60_000); + }); + + it("reads the calendar's own name where it gives one", () => { + expect(parseIcs(cal(`X-WR-CALNAME:Team calendar\r\n${event("UID:d\r\nDTSTART:20260904T090000Z")}`)).name).toBe("Team calendar"); + }); + + it("unfolds a long summary before reading it", () => { + const { events } = parseIcs(cal("BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260904T090000Z\r\nSUMMARY:A very\r\n long title\r\nEND:VEVENT")); + expect(events[0]!.summary).toBe("A very long title"); + }); + + it("steps over components that are not events", () => { + const doc = cal(`BEGIN:VTIMEZONE\r\nTZID:Europe/London\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n${event("UID:f\r\nSUMMARY:Real\r\nDTSTART:20260904T090000Z")}\r\nBEGIN:VTODO\r\nSUMMARY:Not an event\r\nEND:VTODO`); + const { events } = parseIcs(doc); + expect(events.map((e) => e.summary)).toEqual(["Real"]); + }); + + it("counts a recurring event once and does not expand it", () => { + // Showing the wrong dates would be worse than showing the first and saying so. + const { events, recurringCount } = parseIcs(cal(event("UID:g\r\nSUMMARY:Weekly\r\nDTSTART:20260904T090000Z\r\nRRULE:FREQ=WEEKLY;COUNT=10"))); + expect(events).toHaveLength(1); + expect(events[0]!.recurring).toBe(true); + expect(recurringCount).toBe(1); + }); + + it("drops an event with no usable start rather than inventing a time", () => { + const { events } = parseIcs(cal(event("UID:h\r\nSUMMARY:When?"))); + expect(events).toEqual([]); + }); + + it("repairs an end that is before its start", () => { + const { events } = parseIcs(cal(event("UID:i\r\nDTSTART:20260904T100000Z\r\nDTEND:20260904T090000Z"))); + expect(events[0]!.end.getTime()).toBeGreaterThanOrEqual(events[0]!.start.getTime()); + }); + + it("gives an event with no UID one of its own, so keys stay unique", () => { + const { events } = parseIcs(cal(`${event("SUMMARY:One\r\nDTSTART:20260904T090000Z")}\r\n${event("SUMMARY:Two\r\nDTSTART:20260905T090000Z")}`)); + expect(events).toHaveLength(2); + expect(events[0]!.uid).not.toBe(events[1]!.uid); + }); + + it("reads several events, and survives an empty document", () => { + const many = cal([1, 2, 3].map((n) => event(`UID:m${n}\r\nSUMMARY:E${n}\r\nDTSTART:2026090${n}T090000Z`)).join("\r\n")); + expect(parseIcs(many).events.map((e) => e.summary)).toEqual(["E1", "E2", "E3"]); + expect(parseIcs("").events).toEqual([]); + expect(parseIcs("").events).toEqual([]); + }); +}); diff --git a/web/src/lib/ics.ts b/web/src/lib/ics.ts new file mode 100644 index 0000000..e2304f2 --- /dev/null +++ b/web/src/lib/ics.ts @@ -0,0 +1,261 @@ +/** + * Reading an iCalendar document (RFC 5545), enough of one to draw it. + * + * This is a *subscription* parser, not an importer. A subscribed calendar is + * read-only and redrawn from scratch on every refresh, so nothing here has to + * round-trip, survive an edit, or preserve a property it does not understand — + * which is most of what makes a full iCalendar implementation large. What it + * has to do is never mis-state a time, and never hang on a document somebody + * else wrote. + * + * Recurrence is deliberately not expanded. `RRULE` is a small language with a + * lot of edge cases, and a subscription that quietly showed the wrong dates + * would be worse than one that shows the first occurrence and says so. + */ + +export interface IcsEvent { + uid: string; + summary: string; + start: Date; + end: Date; + allDay: boolean; + location?: string; + description?: string; + /** True when the source carried an RRULE that has not been expanded. */ + recurring: boolean; +} + +/** + * Undo the line folding RFC 5545 requires: a continuation is any line starting + * with a space or a tab, and it joins the one before with nothing between. + */ +export function unfold(text: string): string[] { + const out: string[] = []; + for (const raw of text.split(/\r\n|\n|\r/)) { + if ((raw.startsWith(" ") || raw.startsWith("\t")) && out.length) out[out.length - 1] += raw.slice(1); + else out.push(raw); + } + return out; +} + +interface Line { + name: string; + params: Record; + value: string; +} + +/** + * One content line, as `NAME;PARAM=VALUE:the value`. + * + * The colon that ends the name is the first one *outside* a quoted parameter, + * because a parameter may legally contain one — `DTSTART;TZID="GMT+01:00":…` + * is a real thing that a naive `indexOf(":")` reads as a property called + * `DTSTART;TZID="GMT+01`. + */ +export function parseLine(line: string): Line | null { + let quoted = false; + let colon = -1; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') quoted = !quoted; + else if (ch === ":" && !quoted) { + colon = i; + break; + } + } + if (colon < 0) return null; + const head = line.slice(0, colon); + const value = line.slice(colon + 1); + const parts: string[] = []; + let current = ""; + quoted = false; + for (const ch of head) { + if (ch === '"') quoted = !quoted; + if (ch === ";" && !quoted) { + parts.push(current); + current = ""; + } else current += ch; + } + parts.push(current); + const name = (parts.shift() ?? "").toUpperCase(); + if (!name) return null; + const params: Record = {}; + for (const p of parts) { + const eq = p.indexOf("="); + if (eq < 0) continue; + params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, ""); + } + return { name, params, value }; +} + +/** `\n`, `\,`, `\;` and `\\` are escapes in a TEXT value; nothing else is. */ +export function unescapeText(value: string): string { + return value.replace(/\\([nN,;\\])/g, (_, ch: string) => (ch === "n" || ch === "N" ? "\n" : ch)); +} + +/** + * A DATE or DATE-TIME value. + * + * Three forms, and the difference between them is the whole of why calendars + * are hard: + * + * - `20260904` — a date. All-day, and it means that date wherever the reader + * is, so it is built in local time rather than at UTC midnight, which would + * land on the day before for anyone west of Greenwich. + * - `20260904T140000Z` — an instant, in UTC. + * - `20260904T140000` — a wall clock, with a `TZID` naming where. Without a + * library this cannot be converted exactly, so it is read as local time: + * right for the overwhelmingly common case of a calendar published in the + * reader's own zone, and wrong by the offset otherwise. That limit is + * stated rather than hidden. + */ +export function parseDateValue(value: string, params: Record = {}): { date: Date; allDay: boolean } | null { + const v = value.trim(); + const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v); + if (dateOnly || params.VALUE === "DATE") { + const m = dateOnly ?? /^(\d{4})(\d{2})(\d{2})/.exec(v); + if (!m) return null; + return { date: new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])), allDay: true }; + } + const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v); + if (!m) return null; + const [, y, mo, d, h, mi, se, z] = m; + if (z) { + return { date: new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se))), allDay: false }; + } + return { date: new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se)), allDay: false }; +} + +/** An RFC 5545 DURATION, as seconds. Only the forms a DTEND substitute uses. */ +export function parseIcsDuration(value: string): number | null { + const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value.trim()); + if (!m) return null; + const [, sign, w, d, h, mi, s] = m; + const total = (Number(w ?? 0) * 604800) + (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(mi ?? 0) * 60) + Number(s ?? 0); + return sign === "-" ? -total : total; +} + +/** Whether a document is plausibly a calendar, rather than an error page. */ +export function looksLikeCalendar(text: string): boolean { + return /^\s*BEGIN:VCALENDAR/im.test(text); +} + +export interface ParseResult { + events: IcsEvent[]; + /** The calendar's own name, where it gave one. */ + name: string | null; + /** Events skipped because they carried a recurrence rule. */ + recurringCount: number; +} + +/** + * Every VEVENT in the document. + * + * VTODO, VJOURNAL, VFREEBUSY and VTIMEZONE are stepped over rather than + * half-read. An event with no usable start is dropped: there is nowhere to + * draw it, and inventing a time is the one thing worse than leaving it out. + */ +export function parseIcs(text: string): ParseResult { + const events: IcsEvent[] = []; + let name: string | null = null; + let recurringCount = 0; + + let current: Partial & { dtend?: Date; duration?: number; endAllDay?: boolean } | null = null; + /** Depth of any component that is not a VEVENT, so its properties are ignored. */ + let skipping = 0; + + for (const raw of unfold(text)) { + const line = parseLine(raw); + if (!line) continue; + const { name: prop, params, value } = line; + + if (prop === "BEGIN") { + const kind = value.trim().toUpperCase(); + if (kind === "VEVENT" && !skipping) current = { recurring: false }; + else if (kind !== "VCALENDAR") skipping++; + continue; + } + if (prop === "END") { + const kind = value.trim().toUpperCase(); + if (kind === "VEVENT" && current) { + const finished = finish(current); + if (finished) { + if (finished.recurring) recurringCount++; + events.push(finished); + } + current = null; + } else if (kind !== "VCALENDAR" && skipping) skipping--; + continue; + } + if (skipping) continue; + + if (!current) { + // Calendar-level properties. X-WR-CALNAME is not in the RFC but is what + // every publisher actually uses to name a calendar. + if (prop === "X-WR-CALNAME") name = unescapeText(value).trim() || null; + continue; + } + + switch (prop) { + case "UID": + current.uid = value.trim(); + break; + case "SUMMARY": + current.summary = unescapeText(value).trim(); + break; + case "LOCATION": + current.location = unescapeText(value).trim(); + break; + case "DESCRIPTION": + current.description = unescapeText(value).trim(); + break; + case "RRULE": + current.recurring = true; + break; + case "DTSTART": { + const parsed = parseDateValue(value, params); + if (parsed) { + current.start = parsed.date; + current.allDay = parsed.allDay; + } + break; + } + case "DTEND": { + const parsed = parseDateValue(value, params); + if (parsed) { + current.dtend = parsed.date; + current.endAllDay = parsed.allDay; + } + break; + } + case "DURATION": + current.duration = parseIcsDuration(value) ?? undefined; + break; + default: + break; + } + } + return { events, name, recurringCount }; +} + +function finish(e: Partial & { dtend?: Date; duration?: number }): IcsEvent | null { + if (!e.start || Number.isNaN(e.start.getTime())) return null; + const allDay = Boolean(e.allDay); + let end: Date; + if (e.dtend && !Number.isNaN(e.dtend.getTime())) end = e.dtend; + else if (typeof e.duration === "number") end = new Date(e.start.getTime() + e.duration * 1000); + // No end and no duration: a date is the whole day, an instant is a moment. + else end = allDay ? new Date(e.start.getTime() + 86400_000) : new Date(e.start.getTime()); + // An end at or before the start is a document being wrong about itself. + if (end.getTime() < e.start.getTime()) end = new Date(e.start.getTime() + (allDay ? 86400_000 : 0)); + return { + uid: e.uid || `${e.start.getTime()}-${e.summary ?? ""}`, + summary: e.summary || "(untitled)", + start: e.start, + end, + allDay, + location: e.location, + description: e.description, + recurring: Boolean(e.recurring), + }; +} diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index b714374..737933b 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -5,6 +5,8 @@ import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browser import { t } from "@/lib/i18n"; import { useContacts } from "./contacts"; import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays"; +import { looksLikeCalendar, parseIcs, type IcsEvent } from "@/lib/ics"; +import { withBase } from "@/lib/basePath"; import { settings, useSettings } from "./settings"; import { useSession } from "./session"; @@ -261,6 +263,11 @@ interface CalendarState { error: string | null; identities: ParticipantIdentity[]; hidden: Record; + /** Events from each subscribed calendar, by subscription id. Never persisted. */ + subscriptionEvents: Record; + /** Why a subscription last failed, if it did. */ + subscriptionErrors: Record; + subscriptionsLoading: boolean; /** Waiting to be opened in the editor; see `EventDraft`. */ draft: EventDraft | null; @@ -273,6 +280,8 @@ interface CalendarState { setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise; loadRange(start: Date, end: Date, force?: boolean): Promise; instancesIn(start: Date, end: Date): EventInstance[]; + /** Re-fetch every subscribed calendar. */ + refreshSubscriptions(): Promise; getEvent(id: Id): Promise; createEvent(event: Partial, calendarId: Id, sendInvites: boolean): Promise; /** Returns the properties that had to be left to the series, if any. */ @@ -334,6 +343,9 @@ export const useCalendar = create((set, get) => ({ error: null, identities: [], hidden: {}, + subscriptionEvents: {}, + subscriptionErrors: {}, + subscriptionsLoading: false, draft: null, async init() { @@ -502,6 +514,42 @@ export const useCalendar = create((set, get) => ({ } }, + async refreshSubscriptions() { + const subs = settings().icalSubscriptions; + if (!subs.length) { + if (Object.keys(get().subscriptionEvents).length) set({ subscriptionEvents: {}, subscriptionErrors: {} }); + return; + } + set({ subscriptionsLoading: true }); + const events: Record = {}; + const errors: Record = {}; + /* + * Sequential rather than parallel. These are other people's servers, and a + * reader with a dozen subscriptions opening the calendar should not put a + * dozen simultaneous requests on them from every device they own. + */ + for (const sub of subs) { + try { + const res = await fetch(withBase(`/api/ics?url=${encodeURIComponent(sub.url)}`), { headers: { "X-Requested-With": "ihasmail" } }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + errors[sub.id] = body.error ?? `HTTP ${res.status}`; + continue; + } + const text = await res.text(); + if (!looksLikeCalendar(text)) { + // A login page answering 200 is the usual shape of this. + errors[sub.id] = "not_calendar"; + continue; + } + events[sub.id] = parseIcs(text).events; + } catch (err) { + errors[sub.id] = (err as Error).message; + } + } + set({ subscriptionEvents: events, subscriptionErrors: errors, subscriptionsLoading: false }); + }, + instancesIn(start, end) { const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get(); /* @@ -524,6 +572,27 @@ export const useCalendar = create((set, get) => ({ }); } } + /* + * Subscribed calendars, from whatever the last refresh fetched. Same funnel + * as the birthdays and for the same reason: no view has to know they are + * not real calendars, and nothing about them is stored. + */ + for (const sub of settings().icalSubscriptions) { + const calId = subscriptionCalendarId(sub.id); + if (hidden[calId]) continue; + const cal = subscriptionCalendar(sub); + for (const e of get().subscriptionEvents[sub.id] ?? []) { + if (e.end <= start || e.start >= end) continue; + birthdays.push({ + key: `${calId}:${e.uid}:${e.start.getTime()}`, + event: synthesiseSubscriptionEvent(sub.id, e), + start: e.start, + end: e.end, + allDay: e.allDay, + calendar: cal, + }); + } + } const ids = new Set(); for (const list of Object.values(ranges)) for (const id of list) ids.add(id); const out: EventInstance[] = []; @@ -596,7 +665,7 @@ export const useCalendar = create((set, get) => ({ * is the check that makes that true of the store as well, whatever calls * it. */ - if (isBirthdayEvent(event.id)) return []; + if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return []; const accountId = get().accountId!; const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope); // An occurrence takes less than the series does, and says so about only @@ -618,7 +687,7 @@ export const useCalendar = create((set, get) => ({ * is the check that makes that true of the store as well, whatever calls * it. */ - if (isBirthdayEvent(event.id)) return; + if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return; const accountId = get().accountId!; const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope); const res = await client.call("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites }); @@ -844,6 +913,42 @@ function synthesiseBirthdayEvent(b: Birthday): CalendarEvent { } as unknown as CalendarEvent; } +/** The virtual calendar id for a subscription; never a JMAP id. */ +export function subscriptionCalendarId(subId: string): string { + return `ihm-ics:${subId}`; +} + +export function isSubscriptionEvent(id: string | null | undefined): boolean { + return Boolean(id?.startsWith("ihm-ics:")); +} + +function subscriptionCalendar(sub: { id: string; name: string; color: string }): Calendar { + return { + id: subscriptionCalendarId(sub.id), + name: sub.name, + color: sub.color, + isSubscribed: true, + isVisible: true, + // Read-only, and honestly so: everything that asks before offering an edit + // reads these rights, so nothing has to know a subscription is special. + myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayAdmin: false, mayDelete: false }, + } as unknown as Calendar; +} + +function synthesiseSubscriptionEvent(subId: string, e: IcsEvent): CalendarEvent { + const local = `${e.start.getFullYear()}-${String(e.start.getMonth() + 1).padStart(2, "0")}-${String(e.start.getDate()).padStart(2, "0")}T${String(e.start.getHours()).padStart(2, "0")}:${String(e.start.getMinutes()).padStart(2, "0")}:00`; + return { + id: `${subscriptionCalendarId(subId)}:${e.uid}`, + calendarIds: { [subscriptionCalendarId(subId)]: true }, + title: e.summary, + start: local, + showWithoutTime: e.allDay, + location: e.location, + description: e.description, + freeBusyStatus: "free", + } as unknown as CalendarEvent; +} + export function toInstance(e: CalendarEvent, calendars: Record): EventInstance | null { const allDay = Boolean(e.showWithoutTime); let start: Date; diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index b7ec549..c94a699 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -41,6 +41,14 @@ export interface Label { visibility?: LabelVisibility; } +/** A calendar subscribed to by URL, read-only and redrawn on every refresh. */ +export interface IcalSubscription { + id: string; + url: string; + name: string; + color: string; +} + export interface Template { id: string; name: string; @@ -152,6 +160,12 @@ export interface Settings { * dates nobody put there is a surprise rather than a feature. */ birthdayCalendar: boolean; + /** + * Calendars subscribed to by URL. The subscription is the setting; the + * events themselves are fetched on demand and never stored, so this follows + * the account the way every other preference does and costs nothing to sync. + */ + icalSubscriptions: IcalSubscription[]; /** What order the message list is in. See lib/listSort.ts. */ listSortPreset: SortPreset; listSortLevels: SortLevel[]; @@ -267,6 +281,7 @@ export const DEFAULT_SETTINGS: Settings = { timeZone: null, labelsSidebar: true, birthdayCalendar: false, + icalSubscriptions: [], listSortPreset: "newest", listSortLevels: [], listSortScope: "inbox", diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 760890f..c41329d 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -1,10 +1,11 @@ import { useMemo, useRef, useState, useEffect } from "react"; import { useLocation } from "wouter"; -import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } from "lucide-react"; import { useCalendar } from "@/store/calendar"; import { dateTimeKey, useSettings } from "@/store/settings"; import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays"; +import { subscriptionCalendarId } from "@/store/calendar"; import { useContacts } from "@/store/contacts"; import { formatMonthYear } from "@/lib/format"; import { formatWeekday } from "@/lib/datetime"; @@ -65,6 +66,17 @@ export function CalendarSidebar() { if (!cal.available) return null; const birthdaysOn = useSettings((st) => st.settings.birthdayCalendar); + const subscriptions = useSettings((st) => st.settings.icalSubscriptions); + /* + * Refreshed when the calendar is opened, and not on a timer. ihasmail has + * nowhere to run a schedule -- no worker, no server-side state -- so the + * honest guarantee is that a subscription is as current as the last time + * somebody looked, which is also when it matters. + */ + useEffect(() => { + if (subscriptions.length) void useCalendar.getState().refreshSubscriptions(); + }, [subscriptions]); + /* * The cards have to be loaded for there to be any birthdays to derive, and * the calendar is a view somebody can land on directly without ever opening @@ -109,6 +121,26 @@ export function CalendarSidebar() { {t("Birthdays")} )} + {subscriptions.map((sub) => { + const id = subscriptionCalendarId(sub.id); + const failed = cal.subscriptionErrors[sub.id]; + const count = cal.subscriptionEvents[sub.id]?.length ?? 0; + return ( +
cal.toggleHidden(id)} + title={failed ? t("Could not read this calendar: {reason}", { reason: failed }) : t("Subscribed to {url}", { url: sub.url })} + > + + {sub.name} + {/* A subscription that cannot be read says so here rather than + drawing an empty calendar, which looks like a calendar with + nothing in it. */} + {failed ? : count === 0 ? null : null} +
+ ); + })} {calendars.map((c) => (
cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}> diff --git a/web/src/views/settings/CalendarSettings.tsx b/web/src/views/settings/CalendarSettings.tsx index 97a0007..e7495bf 100644 --- a/web/src/views/settings/CalendarSettings.tsx +++ b/web/src/views/settings/CalendarSettings.tsx @@ -61,6 +61,49 @@ export function CalendarSettings() { ))} +

{t("Subscribed calendars")}

+

+ {t("A calendar published at a URL — a timetable, a rota, a public holiday list. It is read-only, refreshed when you open the calendar, and never stored: the events are fetched and kept only for as long as this tab is open.")} +

+ {s.icalSubscriptions.map((sub) => ( +
+
+ +

{sub.name}

+ +
+
{sub.url}
+
+ update({ icalSubscriptions: s.icalSubscriptions.map((x) => (x.id === sub.id ? { ...x, color: c } : x)) })} /> +
+
+ ))} + +

{t("Birthdays")}