openvibers/openvibe.today Commits

Import any calendar by ICS address into your week (#45)

OP OpenVibers committed 12 days ago · cb3c56d70c · 14 files, +1135 −22

* Import any calendar by ICS address into your week

Plan L48 R3 (the owner: "import any calendar by ICS address"). A person adds up to
five calendar addresses on their agenda (https, or the webcal:// form of one, which
becomes https); each is read at most once every half hour and the copy is what the
week is drawn from, so an address that cannot be reached for a moment does not empty
the week. Their events show on /me/week as their own kind (is-import), with the
calendar's short name, at local times in the person's own zone, and an all-day event
at the top of its day.

The address is read through the shared egress rule (openvibe-shared/egress): https
only, a public host only, a name that resolves to a private or link-local address
refused before a byte is sent and again at connect time, a redirect followed only
after the same check, a 10 s timeout, at most 2 MB, and only text/calendar or
text/plain read. server/ical.js reads RFC 5545 far enough and no further: SUMMARY,
DTSTART/DTEND with a TZID or an all-day DATE, DURATION, EXDATE, STATUS:CANCELLED,
and a RRULE that is DAILY, WEEKLY with BYDAY or MONTHLY by the day of the month,
with INTERVAL, COUNT and UNTIL. A rule with anything else in it, a zone this server
does not know and an event with no DTSTART are left out and counted, never guessed
at.

The address belongs to the person: it is never exported and it goes with the
account. No background job: the copy on the page already bounds the reads.

* A rule ends at its UNTIL, for an all-day event too

Changes

ddiff --git a/README.md b/README.md
iindex 410cdc0..4c39508 100644
---- a/README.md
++++ b/README.md
@@@ -38,6 +38,8 @@ One schedule for the whole network:
| `today_outbox` | the fixed `evt_` id, topic, JSON payload, attempts, last error and a claim/backoff lease | `0001_initial.sql` |
| `today_processed` | inbound Events ids already handled, so a replay is a no-op | `0001_initial.sql` |
| `today_briefs` | (`subject`, `day`) → the sections, one per person per day | `0001_initial.sql` |
++| `today_calendar_imports` | the addresses a person imported: `cal_` + ULID, `subject` (`usr_…`), the https `url`, its short `name`, when it was last read and why the last read failed; at most five per person | `migrations/0004_calendar_imports.sql` |
++| `today_calendar_import_cache` | the copy of each imported calendar, read at most once every half hour; it goes with the address | `0004_calendar_imports.sql` |
| `account_data_events` | the receipts openvibe-sdk/account-data keeps for export and deletion deliveries | `0001_initial.sql` |
| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
@@@ -170,7 +172,8 @@ HTML and stable `td-` ids/classes:
| `/` | the room's front door: the day's ring (today's events on the reader's clock), what is coming up by day, three scenes |
| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` |
| `/e/:id` | one event, with `application/ld+json` `Event` (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (`POST /e/:id/remind`, `/remind/remove`), Add to calendar (`/e/:id.ics`), Watch on Live, and Cancel for its owner (`POST /e/:id/cancel`) |
--| `/me` | agenda, reminders, the feed URL and its rotate form |
++| `/me` | agenda, reminders, the calendars you imported (a form and a list, with a Remove button), the feed URL and its rotate form |
++| `/me/week` | your week: seven days from Monday in your own zone — your reminders at each time they fire, your events, the streams of the channels you follow, and the events of the calendars you imported |
| `/brief` | today's brief |
| `/schedule` | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (`POST /schedule` → 303 `/e/:id`) |
| `/developers` | the API |
@@@ -194,7 +197,10 @@ The tests boot the app with a mock Network and Events: events (validation, idemp
transactional outbox), ICS (folding, escaping, cancellation window, token rotation), the consumer (signed, replay, bad
signature), the outbox (fixed id, backoff, restart), reminders (one notification, concurrency, cancelled), agenda
(follows and Network down), imports (idempotent, time zones, source links, retirement, no private fields), brief
--(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery.
++(sections, Rundown failure, one per day), pages, sign-in, account export/deletion and discovery. The imported calendars
++are tested on their own: reading a calendar (a zone, an all-day event, a weekly rule, an excluded day, a cancelled
++event, a clock change), refusing a private address and a body that is too big, the week that shows them, the five
++addresses a person may keep, and a form post from another site refused.
## Imports
@@@ -206,6 +212,23 @@ in Live's studio writes them into this service's own table. The job runs at boot
keeps its `source`/`source_id`, its source URL and its time zone; a second run updates the same row, and a source
item that disappears retires its row.
++## Imported calendars
++
++A person can add up to five calendar addresses on their agenda (`POST /me/calendars`, `POST /me/calendars/:id/remove`):
++`https://…` or the `webcal://` form of one, which becomes `https://`. The address is read through the shared egress
++rule (`openvibe-shared/egress`): https only, a public host only, a name that resolves to a private or link-local
++address refused before a byte is sent and again at connect time, a redirect followed only after the same check, a 10 s
++timeout, at most 2 MB, and only `text/calendar` or `text/plain` is read. Each address is read at most once every
++half hour, and the copy is what the week is drawn from, so an address that cannot be reached for a moment does not
++empty the week.
++
++`server/ical.js` reads RFC 5545 far enough and no further: `SUMMARY`, `DTSTART`/`DTEND` with a `TZID` or an
++all-day `DATE`, `DURATION`, `EXDATE`, `STATUS:CANCELLED`, and a `RRULE` that is `DAILY`, `WEEKLY` with
++`BYDAY` or `MONTHLY` by the day of the month, with `INTERVAL`, `COUNT` and `UNTIL`. A rule with anything else
++in it (`BYMONTH`, `BYSETPOS`, an ordinal `BYDAY` such as `2FR`), a zone this server does not know and an event
++with no `DTSTART` are left out and counted, never guessed at. The address belongs to the person: it is never
++exported and it goes with the account.
++
## Deploy
`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and
ddiff --git a/migrations/0004_calendar_imports.sql b/migrations/0004_calendar_imports.sql
nnew file mode 100644
iindex 0000000..2f970f2
---- /dev/null
++++ b/migrations/0004_calendar_imports.sql
@@@ -0,0 +1,26 @@
++-- phase: expand
++-- The calendars a person imports (plan L48 R3): the addresses they added, and the copy of each one Today last read.
++-- The address belongs to the person: it is never exported and it goes with the account (server/identity/account-data.js).
++--
++-- today_calendar_imports is the person's list. today_calendar_import_cache is the copy of the calendar itself: a
++-- calendar is read at most once every half hour, and the week is drawn from the copy, so an address that cannot
++-- be reached for a moment does not empty the week.
++
++CREATE TABLE IF NOT EXISTS today_calendar_imports (
++ id text COLLATE "C" PRIMARY KEY, -- cal_ + ULID
++ subject text COLLATE "C" NOT NULL, -- usr_… the person
++ url text NOT NULL, -- the https address (webcal:// is turned into https://)
++ name text NOT NULL, -- the short name the week shows
++ last_fetched_at timestamptz, -- when the copy was taken
++ last_error text, -- why the last read did not work
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE UNIQUE INDEX IF NOT EXISTS today_calendar_imports_url ON today_calendar_imports (subject, url);
++CREATE INDEX IF NOT EXISTS today_calendar_imports_by_subject ON today_calendar_imports (subject, created_at);
++
++CREATE TABLE IF NOT EXISTS today_calendar_import_cache (
++ import_id text COLLATE "C" PRIMARY KEY REFERENCES today_calendar_imports(id) ON DELETE CASCADE,
++ body text NOT NULL, -- the calendar as it was read (RFC 5545)
++ fetched_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/public/css/today.css b/public/css/today.css
iindex bf730b3..a31b37c 100644
---- a/public/css/today.css
++++ b/public/css/today.css
@@@ -252,6 +252,10 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.td-wi.is-band { --k: color-mix(in srgb, var(--room-accent) 55%, #22c55e); }
.td-wi.is-event { --k: #a78bfa; }
.td-wi.is-stream { --k: #f43f5e; }
++.td-wi.is-import { --k: #38bdf8; }
++/* An imported event has no page of its own here: it is shown, not linked. */
++.td-wi a:not([href]) { cursor: default; }
++.td-wi a:not([href]):hover { background: color-mix(in srgb, var(--text-primary, #e8eef8) 4%, transparent); }
.td-pweek { display: none; list-style: none; margin: 18px 0 0; padding: 0; }
.td-pd { padding: 12px 0; border-top: 1px solid var(--border, #1f2d47); }
.td-pd-h { margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; font-size: 15px; font-weight: 750; }
@@@ -266,6 +270,11 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.td-wi-m { grid-column: 3; }
}
@media (max-width: 520px) { .td-quick { flex-direction: column; } .td-quick .ovk-btn { align-self: flex-start; } }
++/* The form that adds a calendar: the address, a name for it, and the button. */
++.td-cal-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 150px) auto; gap: 8px; margin-top: 10px; }
++.td-cal-form input { min-width: 0; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; font-size: 1rem; }
++.td-cal-form input:focus { outline: 2px solid var(--room-strong, var(--accent, #4f8cff)); outline-offset: 1px; }
++@media (max-width: 640px) { .td-cal-form { grid-template-columns: minmax(0, 1fr); } }
/* The week's lines read as text (the room's link colour belongs to links in prose), and their times line up. */
.td-wrap .td-wi a, .td-wrap .td-wi-t { color: var(--text-primary, #e8eef8); }
.td-wi time { min-width: 2.9em; }
ddiff --git a/server/app.js b/server/app.js
iindex 6404dd7..b3db8ce 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -71,6 +71,7 @@ async function createApp(opts = {}) {
const brief = createBrief({ config, s, agenda, fetchImpl, log });
const notifier = createNotifier({ config, s, fetchImpl, log });
const mine = require('./my-reminders').createMyReminders({ s });
++ const calendarImports = require('./calendar-imports').createCalendarImports({ s, log, fetchImpl: opts.calendarFetch });
const reminders = createReminders({ config, s, events, mine, notifier, log });
const publisher = createPublisher({ config, s, fetchImpl, log });
const imports = createImports({ config, s, fetchImpl, log, now: s.now });
@@@ -85,7 +86,7 @@ async function createApp(opts = {}) {
const ctx = {
config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, mine, publisher,
-- imports, accountData, accountSend, consumer, log,
++ imports, calendarImports, accountData, accountSend, consumer, log,
};
const app = express();
ddiff --git a/server/calendar-imports.js b/server/calendar-imports.js
nnew file mode 100644
iindex 0000000..1e9aba7
---- /dev/null
++++ b/server/calendar-imports.js
@@@ -0,0 +1,159 @@
++'use strict';
++/**
++ * The calendars a person imports (plan L48 R3: "import any calendar by ICS address"). This keeps the addresses they
++ * added, reads them at most once every half hour, and gives back the events of a week.
++ *
++ * add(subject, { url, name }) → the import, or { error } (a person keeps at most MAX_PER_PERSON)
++ * list(subject) → the person's imports
++ * remove(subject, id) → true when it was there
++ * events(subject, { from, to, tz }) → { events, problems }
++ *
++ * An address is read at most once every half hour: the copy Today read last is kept, and a view takes a fresh
++ * one only when that copy is older than half an hour. A calendar that cannot be read does not empty the week: the
++ * copy that is there stays, and the address is marked with the reason.
++ *
++ * The address belongs to the person: it is never exported and it goes with the account (server/identity/
++ * account-data.js).
++ */
++const ical = require('./ical');
++const { fetchCalendar: guardedFetch } = require('./net/calendar-fetch');
++
++const MAX_PER_PERSON = 5; // the most addresses one person may keep
++const TTL_MS = 30 * 60_000; // a calendar is read at most once every half hour
++const TIMEOUT_MS = 10_000; // how long an address may take
++const MAX_BYTES = 2 * 1024 * 1024; // the most a calendar may weigh
++
++/** The address a person may add: https, or webcal:// turned into https. */
++function normalizeUrl(value) {
++ let s = String(value == null ? '' : value).trim();
++ if (!s) return null;
++ if (/^webcal:\/\//i.test(s)) s = 'https://' + s.slice('webcal://'.length);
++ else if (!/^https:\/\//i.test(s)) return null;
++ let u;
++ try { u = new URL(s); } catch { return null; }
++ if (u.username || u.password) return null;
++ if (!u.hostname || !u.hostname.includes('.')) return null; // a public host name, not this machine
++ u.hash = '';
++ return u.toString();
++}
++
++/** The short name of a calendar: what the person called it, or the first word of its host. */
++function shortName(url, name) {
++ const given = String(name == null ? '' : name).trim().replace(/\s+/g, ' ').slice(0, 60);
++ if (given) return given;
++ let host = '';
++ try { host = new URL(url).hostname; } catch { host = ''; }
++ host = host.replace(/^www\./, '');
++ const word = host.split('.')[0] || host;
++ return (word.charAt(0).toUpperCase() + word.slice(1)).slice(0, 40);
++}
++
++/**
++ * The address as it is shown on the agenda: the host, and the path too when it is short. A path is often a secret
++ * (a calendar's own token), so a long one is left out rather than shown half.
++ */
++function shortAddress(url) {
++ let u;
++ try { u = new URL(url); } catch { return String(url).slice(0, 60); }
++ const host = u.hostname.replace(/^www\./, '');
++ const path = u.pathname.replace(/^\/+|\/+$/g, '');
++ if (!path) return host;
++ return path.length <= 20 ? host + '/' + path : host;
++}
++
++function wire(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ url: row.url,
++ name: row.name,
++ short: shortAddress(row.url),
++ last_fetched_at: row.last_fetched_at ? new Date(row.last_fetched_at).toISOString() : null,
++ last_error: row.last_error || null,
++ created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
++ };
++}
++
++function createCalendarImports({ s, log = console, fetchImpl = null } = {}) {
++ const db = s.db;
++ const fetchOne = fetchImpl || guardedFetch;
++
++ /** The person's addresses. */
++ async function list(subject) {
++ const rows = await db.many('SELECT * FROM today_calendar_imports WHERE subject = $1 ORDER BY created_at', [subject]);
++ return rows.map(wire);
++ }
++
++ /** Add an address. */
++ async function add(subject, { url, name = null }) {
++ const address = normalizeUrl(url);
++ if (!address) return { error: 'That is not an address Today can read. Use an https link, or the webcal:// form of one.' };
++ const count = Number(await db.value('SELECT count(*)::int FROM today_calendar_imports WHERE subject = $1', [subject])) || 0;
++ if (count >= MAX_PER_PERSON) return { error: 'You have ' + MAX_PER_PERSON + ' calendars already. Remove one to add another.' };
++ const seen = await db.maybe('SELECT id FROM today_calendar_imports WHERE subject = $1 AND url = $2', [subject, address]);
++ if (seen) return { error: 'That calendar is already on your list.' };
++ const row = await db.maybe(
++ 'INSERT INTO today_calendar_imports (id, subject, url, name) VALUES ($1, $2, $3, $4) RETURNING *',
++ [s.newId('cal'), subject, address, shortName(address, name)],
++ );
++ return wire(row);
++ }
++
++ /** Take an address away. The copy of the calendar goes with it. */
++ async function remove(subject, id) {
++ const r = await db.maybe('DELETE FROM today_calendar_imports WHERE id = $1 AND subject = $2 RETURNING id', [String(id || ''), subject]);
++ return !!r;
++ }
++
++ /**
++ * The events of the window, from the copies. An address whose copy is older than half an hour is read again
++ * first; the ones that cannot be read keep the copy they have.
++ */
++ async function events(subject, { from, to, tz = 'UTC' } = {}) {
++ const rows = await db.many('SELECT * FROM today_calendar_imports WHERE subject = $1 ORDER BY created_at', [subject]);
++ if (!rows.length) return { events: [], problems: [] };
++ const now = s.now();
++ const due = [];
++ for (const row of rows) {
++ const cached = await db.maybe('SELECT body, fetched_at FROM today_calendar_import_cache WHERE import_id = $1', [row.id]);
++ const age = cached ? now - new Date(cached.fetched_at).getTime() : Infinity;
++ due.push({ row, body: cached ? cached.body : null, age });
++ }
++ const problems = [];
++ // The addresses that are due are read together, so one slow calendar does not hold up the others.
++ await Promise.all(due.filter((d) => d.age > TTL_MS).map(async (d) => {
++ try {
++ const res = await fetchOne(d.row.url, { timeoutMs: TIMEOUT_MS, maxBytes: MAX_BYTES });
++ await db.maybe(
++ 'INSERT INTO today_calendar_import_cache (import_id, body, fetched_at) VALUES ($1, $2, $3::timestamptz) '
++ + 'ON CONFLICT (import_id) DO UPDATE SET body = EXCLUDED.body, fetched_at = EXCLUDED.fetched_at',
++ [d.row.id, String(res.body), new Date(now).toISOString()],
++ );
++ await db.exec(
++ 'UPDATE today_calendar_imports SET last_fetched_at = $2::timestamptz, last_error = NULL, updated_at = $2::timestamptz WHERE id = $1',
++ [d.row.id, new Date(now).toISOString()],
++ );
++ d.body = String(res.body);
++ } catch (err) {
++ const message = String(err && err.message ? err.message : err).slice(0, 200);
++ await db.exec('UPDATE today_calendar_imports SET last_error = $2, updated_at = now() WHERE id = $1', [d.row.id, message]);
++ log.warn('[OpenVibe.Today] a calendar could not be read: ' + message);
++ problems.push({ id: d.row.id, name: d.row.name, message });
++ // The copy that is there stays: a calendar that cannot be read does not empty the week.
++ }
++ }));
++ const out = [];
++ for (const d of due) {
++ if (!d.body) continue;
++ const parsed = ical.occurrences(d.body, { from, to, tz });
++ for (const e of parsed.events) {
++ out.push({ ...e, calendar: d.row.name, calendarId: d.row.id, source: d.row.url });
++ }
++ }
++ return { events: out, problems };
++ }
++
++ return { list, add, remove, events, wire, MAX_PER_PERSON, TTL_MS, TIMEOUT_MS, MAX_BYTES };
++}
++
++module.exports = { createCalendarImports, normalizeUrl, shortName, shortAddress, MAX_PER_PERSON, TTL_MS, TIMEOUT_MS, MAX_BYTES };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex cc7ffb6..1d2ec39 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -115,7 +115,7 @@ function createPublicPageViews(ctx) {
}
function createPageRoutes(ctx) {
-- const { config, s, events, feeds, agenda, brief, mine } = ctx;
++ const { config, s, events, feeds, agenda, brief, mine, calendarImports } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const base = () => String(config.baseUrl).replace(/\/+$/, '');
@@@ -204,10 +204,11 @@ function createPageRoutes(ctx) {
const reminders = await events.listReminders(p.subject);
const token = await feeds.getOrCreate(p.subject);
const own = mine ? await mine.list(p.subject) : [];
++ const cals = calendarImports ? await calendarImports.list(p.subject) : [];
return page(req, res, {
title: 'Your agenda',
description: 'Your own events, the reminders you set, the channels you follow and your private calendar feed.',
-- body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now(), mine: own, problem: String(req.query.problem || '').slice(0, 200) || null, said: String(req.query.said || '').slice(0, 500) })),
++ body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now(), mine: own, myCalendars: cals, problem: String(req.query.problem || '').slice(0, 200) || null, said: String(req.query.said || '').slice(0, 500), calProblem: String(req.query.cal_problem || '').slice(0, 200) || null, calSaid: String(req.query.cal_url || '').slice(0, 500) })),
});
});
@@@ -226,11 +227,15 @@ function createPageRoutes(ctx) {
const { wire: wireEvent } = require('../events/store');
const reminded = new Set((await s.db.many('SELECT event_id FROM today_reminders WHERE subject = $1', [p.subject])).map((x) => x.event_id));
const events = rows.map((x) => ({ ...wireEvent(x), own: x.owner_subject === p.subject, reminded: reminded.has(x.id) }));
-- const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events });
++ // The calendars they imported: read at most once every half hour, and drawn from the copy.
++ const imported = calendarImports
++ ? await calendarImports.events(p.subject, { from: Date.parse(first.from), to: Date.parse(first.to), tz })
++ : { events: [], problems: [] };
++ const w = require('../week').build({ tz, start, now: s.now(), reminders: own, events, imports: imported.events });
return page(req, res, {
title: 'Your week',
description: 'Your reminders, your events and the streams of the channels you follow, day by day.',
-- body: raw(views.week({ name: req.viewer.displayName || req.viewer.username, week: w })),
++ body: raw(views.week({ name: req.viewer.displayName || req.viewer.username, week: w, problems: imported.problems })),
});
});
@@@ -291,6 +296,25 @@ function createPageRoutes(ctx) {
return toMine(res);
});
++ // ── The calendars a person imports (plan L48 R3): plain forms, so the page works without its script ──
++ const calendarGuard = (req, res, next) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fme%23calendars');
++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);
++ return next();
++ };
++ r.post('/me/calendars', express.urlencoded({ extended: false, limit: '8kb' }), calendarGuard, async (req, res) => {
++ const b = req.body || {};
++ const out = await calendarImports.add(req.viewer.subject, { url: b.url, name: b.name });
++ if (out.error) {
++ return res.redirect(303, `/me?cal_problem=${encodeURIComponent(out.error)}&cal_url=${encodeURIComponent(String(b.url || '').slice(0, 500))}`);
++ }
++ return res.redirect(303, '/me#calendars');
++ });
++ r.post('/me/calendars/:id/remove', express.urlencoded({ extended: false, limit: '2kb' }), calendarGuard, async (req, res) => {
++ await calendarImports.remove(req.viewer.subject, String(req.params.id || ''));
++ return res.redirect(303, '/me#calendars');
++ });
++
r.post('/me/feed/rotate', async (req, res) => {
if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('A feed belongs to an account.', '/me')}` }, 401);
if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Replace a feed from <a href="/me">your agenda</a>.</p>` }, 403);
ddiff --git a/server/ical.js b/server/ical.js
nnew file mode 100644
iindex 0000000..7bff817
---- /dev/null
++++ b/server/ical.js
@@@ -0,0 +1,449 @@
++'use strict';
++/**
++ * Reading a calendar in iCalendar (RFC 5545), far enough and no further (plan L48 R3: "import any calendar by ICS
++ * address"). Pure: it reads text and gives back events; it fetches nothing and writes nothing, so every reading
++ * can be tried on its own.
++ *
++ * What is read:
++ * VEVENT with SUMMARY, DTSTART and DTEND (a DATE-TIME with TZID, a DATE for an all-day event, or a time in
++ * UTC), DURATION when there is no DTEND, EXDATE, and STATUS (a CANCELLED event is not shown);
++ * RRULE with FREQ=DAILY, FREQ=WEEKLY with BYDAY, or FREQ=MONTHLY by the day of the month, each with INTERVAL,
++ * COUNT and UNTIL;
++ * a RECURRENCE-ID: the instance it names is left out of the series, and the moved instance is shown where it
++ * says it is.
++ *
++ * What is not read, on purpose: a rule with anything else in it (BYMONTH, BYSETPOS, an ordinal BYDAY such as
++ * "2FR", a WKST that is not Monday), a zone this server does not know, an event with no DTSTART. A rule or an
++ * event that is not read is left out and counted, never guessed at.
++ *
++ * A time with no zone (a "floating" time: no Z and no TZID) is read in the reader's own zone, because that is
++ * what it means: the wall clock where it was written.
++ *
++ * occurrences(text, { from, to, tz }) → { events, skipped }
++ * events: [{ uid, title, start, end, allDay, date, tz }] (start and end are epoch ms; an all-day event
++ * has a date "YYYY-MM-DD" and no instants)
++ * skipped: { reason: how many } what was left out and why
++ */
++const when = require('./when');
++
++const MAX_STEPS = 4000; // the most recurrence steps one rule may take
++const MAX_EVENTS = 2000; // the most events one calendar may give in one window
++const MAX_TITLE = 200;
++const DAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
++const SUPPORTED_PARTS = new Set(['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY', 'WKST']);
++
++const pad2 = (n) => String(n).padStart(2, '0');
++const keyOf = (d) => d.y + pad2(d.m) + pad2(d.d);
++const dayNum = (d) => Date.UTC(d.y, d.m - 1, d.d) / 86400000;
++const isLeap = (y) => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
++const daysInMonth = (y, m) => [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1] || 30;
++/** y-m-d plus n days, as a calendar date. */
++function addDays(d, n) {
++ const t = new Date(Date.UTC(d.y, d.m - 1, d.d + n));
++ return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate() };
++}
++/** The weekday of a date: 1 Monday … 7 Sunday. */
++const dowOf = (d) => ((new Date(Date.UTC(d.y, d.m - 1, d.d)).getUTCDay() + 6) % 7) + 1;
++
++// ── The text ────────────────────────────────────────────────────────────────────────────────────────────────────
++/** RFC 5545 folds a long line: a line break followed by a space or a tab is one line. */
++function unfold(text) {
++ return String(text == null ? '' : text)
++ .replace(/^\uFEFF/, '')
++ .replace(/\r\n[ \t]|\n[ \t]|\r[ \t]/g, '')
++ .split(/\r\n|\n|\r/);
++}
++/** "SUMMARY;LANGUAGE=en:Hello" → { name, params, value }. */
++function parseLine(line) {
++ const colon = line.indexOf(':');
++ if (colon < 0) return null;
++ const bits = line.slice(0, colon).split(';');
++ const name = bits[0].trim().toUpperCase();
++ if (!name) return null;
++ const params = {};
++ for (const b of bits.slice(1)) {
++ const eq = b.indexOf('=');
++ if (eq < 0) continue;
++ const key = b.slice(0, eq).trim().toUpperCase();
++ let v = b.slice(eq + 1).trim();
++ if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
++ if (params[key] === undefined) params[key] = v;
++ }
++ return { name, params, value: line.slice(colon + 1) };
++}
++/** The components of a calendar, innermost first. */
++function components(lines) {
++ const stack = [];
++ const out = [];
++ for (const line of lines) {
++ const p = parseLine(line);
++ if (!p) continue;
++ if (p.name === 'BEGIN') { stack.push({ type: String(p.value || '').trim().toUpperCase(), props: [] }); continue; }
++ if (p.name === 'END') { const c = stack.pop(); if (c) out.push(c); continue; }
++ if (stack.length) stack[stack.length - 1].props.push(p);
++ }
++ return out;
++}
++/** The properties of a component, by name. */
++function propsOf(component) {
++ const map = new Map();
++ for (const p of component.props) {
++ const list = map.get(p.name) || [];
++ list.push(p);
++ map.set(p.name, list);
++ }
++ return map;
++}
++const one = (map, name) => (map.get(name) || [null])[0];
++/** A text value: the escapes undone, in one line, no longer than a title. */
++function text(value) {
++ return String(value == null ? '' : value)
++ .replace(/\\([\\;,nN])/g, (_, c) => (c === 'n' || c === 'N') ? '\n' : c)
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, MAX_TITLE);
++}
++
++// ── Dates ──────────────────────────────────────────────────────────────────────────────────────────────────────
++/** A DATE or DATE-TIME property → { kind, y, m, d, h, min, s, utc, tz }. */
++function readDate(prop) {
++ if (!prop) return null;
++ const raw = String(prop.value == null ? '' : prop.value).trim();
++ const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(raw);
++ if (!m) return null;
++ const isDate = String(prop.params.VALUE || '').toUpperCase() === 'DATE' || (!m[4] && !m[7]);
++ const tzid = String(prop.params.TZID || '').trim().replace(/^\//, '');
++ return {
++ kind: isDate ? 'date' : 'datetime',
++ y: +m[1], m: +m[2], d: +m[3],
++ h: m[4] ? +m[4] : 0, min: m[5] ? +m[5] : 0, s: m[6] ? +m[6] : 0,
++ utc: !!m[7],
++ tz: isDate ? null : (tzid || null),
++ };
++}
++/** The zone a time is written in: UTC, a zone this server knows, or null when it cannot be read. */
++function zoneOf(d, readerZone) {
++ if (!d || d.kind === 'date') return 'UTC';
++ if (d.utc) return 'UTC';
++ if (d.tz) return when.validTz(d.tz) ? d.tz : null;
++ return when.validTz(readerZone) ? readerZone : 'UTC';
++}
++/** The instant a wall clock means in a zone (the seconds are added back: the zone helper works to the minute). */
++function instantOf(wall, zone) {
++ if (zone === 'UTC') return Date.UTC(wall.y, wall.m - 1, wall.d, wall.h, wall.min, wall.s);
++ return when.atLocal(wall.y, wall.m, wall.d, wall.h, wall.min, zone).getTime() + wall.s * 1000;
++}
++/** A DURATION property (PT1H30M, P1D, P2W) in milliseconds, or null. */
++function readDuration(prop) {
++ if (!prop) return null;
++ const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(String(prop.value || '').trim().toUpperCase());
++ if (!m) return null;
++ if (!m.slice(1).some((x) => x !== undefined)) return null;
++ return ((+m[1] || 0) * 604800 + (+m[2] || 0) * 86400 + (+m[3] || 0) * 3600 + (+m[4] || 0) * 60 + (+m[5] || 0)) * 1000;
++}
++
++// ── The rule ───────────────────────────────────────────────────────────────────────────────────────────────────
++/**
++ * An RRULE, read only as far as this service reads rules. A part it does not know makes the whole rule
++ * unreadable: the event is left out rather than shown at the wrong times.
++ */
++function readRRule(prop) {
++ if (!prop) return null;
++ const parts = {};
++ for (const kv of String(prop.value || '').split(';')) {
++ const eq = kv.indexOf('=');
++ if (eq < 0) continue;
++ parts[kv.slice(0, eq).trim().toUpperCase()] = kv.slice(eq + 1).trim();
++ }
++ for (const k of Object.keys(parts)) if (!SUPPORTED_PARTS.has(k)) return { error: 'rule:' + k.toLowerCase() };
++ const freq = String(parts.FREQ || '').toUpperCase();
++ if (!['DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) return { error: 'rule:freq' };
++ if (parts.WKST && parts.WKST.toUpperCase() !== 'MO') return { error: 'rule:wkst' };
++ const interval = Math.max(1, parseInt(parts.INTERVAL || '1', 10) || 1);
++ let count = 0;
++ if (parts.COUNT != null) {
++ count = parseInt(parts.COUNT, 10);
++ if (!Number.isFinite(count) || count < 1) return { error: 'rule:count' };
++ }
++ let until = null;
++ if (parts.UNTIL != null) {
++ until = readDate({ value: parts.UNTIL, params: {} });
++ if (!until) return { error: 'rule:until' };
++ }
++ let bydays = null;
++ if (parts.BYDAY != null) {
++ bydays = [];
++ for (const b of String(parts.BYDAY).split(',')) {
++ const code = b.trim().toUpperCase();
++ if (!DAY_CODES.includes(code)) return { error: /^-?\d/.test(code) ? 'rule:byday-ordinal' : 'rule:byday' };
++ bydays.push(DAY_CODES.indexOf(code) + 1);
++ }
++ bydays.sort((a, b) => a - b);
++ }
++ let bymonthday = null;
++ if (parts.BYMONTHDAY != null) {
++ bymonthday = [];
++ for (const b of String(parts.BYMONTHDAY).split(',')) {
++ const n = parseInt(b.trim(), 10);
++ if (!Number.isFinite(n) || n === 0 || n < -31 || n > 31) return { error: 'rule:bymonthday' };
++ bymonthday.push(n);
++ }
++ }
++ if (freq === 'DAILY' && bydays) return { error: 'rule:byday-daily' };
++ if (freq === 'WEEKLY' && bymonthday) return { error: 'rule:bymonthday-weekly' };
++ if (freq === 'MONTHLY' && bydays) return { error: 'rule:byday-monthly' };
++ return { rule: { freq, interval, count, until, bydays, bymonthday } };
++}
++
++// ── The occurrences ───────────────────────────────────────────────────────────────────────────────────────────
++/** The days a rule gives, in order. */
++function* ruleDays(plan, rule, skip) {
++ const step = rule.interval;
++ if (rule.freq === 'DAILY') {
++ let d = addDays(plan.start, skip * step);
++ for (let k = 0; k < MAX_STEPS; k++) { yield d; d = addDays(d, step); }
++ return;
++ }
++ if (rule.freq === 'WEEKLY') {
++ const bydays = rule.bydays || [dowOf(plan.start)];
++ const week0 = addDays(plan.start, -(dowOf(plan.start) - 1));
++ let base = addDays(week0, skip * step * 7);
++ for (let w = 0; w < MAX_STEPS; w++) {
++ for (const b of bydays) yield addDays(base, b - 1);
++ base = addDays(base, step * 7);
++ }
++ return;
++ }
++ // MONTHLY: the day of the month, the same day each month.
++ let y = plan.start.y;
++ let m = plan.start.m + skip * step;
++ y += Math.floor((m - 1) / 12);
++ m = ((m - 1) % 12 + 12) % 12 + 1;
++ for (let k = 0; k < MAX_STEPS; k++) {
++ const dim = daysInMonth(y, m);
++ for (const dom of (rule.bymonthday || [plan.start.d])) {
++ const d = dom > 0 ? dom : dim + 1 + dom;
++ if (d >= 1 && d <= dim) yield { y, m, d };
++ }
++ const t = new Date(Date.UTC(y, m - 1 + step, 1));
++ y = t.getUTCFullYear();
++ m = t.getUTCMonth() + 1;
++ }
++}
++/** "YYYYMMDD" → "YYYY-MM-DD". */
++function isoDay(k) {
++ return k.slice(0, 4) + '-' + k.slice(4, 6) + '-' + k.slice(6, 8);
++}
++/** "YYYYMMDD" → { y, m, d }. */
++function fromDayDate(k) {
++ return { y: +k.slice(0, 4), m: +k.slice(4, 6), d: +k.slice(6, 8) };
++}
++
++/**
++ * The occurrences of one event in the window.
++ * plan: { allDay, start: {y,m,d,h,min,s}, zone, duration, first }
++ * exdates { instants: Set<ms>, dates: Set<YYYYMMDD> }
++ */
++function expand(plan, rule, exdates, window) {
++ const out = [];
++ if (!plan) return out;
++ const { from, to, fromDay, toDay } = window;
++ const startKey = keyOf(plan.start);
++
++ if (!rule) {
++ if (plan.allDay) {
++ const k = keyOf(plan.start);
++ if (k >= fromDay && k <= toDay && !exdates.dates.has(k)) out.push({ date: k });
++ } else {
++ const at = plan.first;
++ if (at >= from && at < to && !exdates.instants.has(at)) out.push({ start: at, end: at + plan.duration });
++ }
++ return out;
++ }
++
++ // UNTIL: an instant for a timed rule, a day for an all-day one.
++ let untilAt = null;
++ let untilDay = null;
++ if (rule.until) {
++ if (plan.allDay) untilDay = keyOf(rule.until);
++ else if (rule.until.utc) untilAt = Date.UTC(rule.until.y, rule.until.m - 1, rule.until.d, rule.until.h, rule.until.min, rule.until.s);
++ else if (rule.until.kind === 'date') untilAt = instantOf({ y: rule.until.y, m: rule.until.m, d: rule.until.d, h: 23, min: 59, s: 59 }, plan.zone);
++ else untilAt = instantOf(rule.until, plan.zone);
++ }
++
++ // A rule with no COUNT may be counted from near the window instead of from its first day.
++ let skip = 0;
++ if (!rule.count) {
++ const fromDay0 = fromDayDate(fromDay);
++ if (rule.freq === 'DAILY') {
++ skip = Math.max(0, Math.floor((dayNum(fromDay0) - dayNum(plan.start)) / rule.interval) - 1);
++ } else if (rule.freq === 'WEEKLY') {
++ const week0 = addDays(plan.start, -(dowOf(plan.start) - 1));
++ skip = Math.max(0, Math.floor((dayNum(fromDay0) - dayNum(week0)) / 7 / rule.interval) - 1);
++ } else {
++ const months = (fromDay0.y - plan.start.y) * 12 + (fromDay0.m - plan.start.m);
++ skip = Math.max(0, Math.floor(months / rule.interval) - 1);
++ }
++ }
++
++ let count = 0;
++ let steps = 0;
++ for (const day of ruleDays(plan, rule, skip)) {
++ if (++steps > MAX_STEPS) break;
++ const k = keyOf(day);
++ if (k < startKey) continue; // before the rule's first day
++ count++;
++ if (rule.count && count > rule.count) break;
++ if (untilDay && k > untilDay) break;
++ const wall = { y: day.y, m: day.m, d: day.d, h: plan.start.h, min: plan.start.min, s: plan.start.s };
++ const at = plan.allDay ? null : instantOf(wall, plan.zone);
++ if (untilAt != null && at != null && at > untilAt) break;
++ if (plan.allDay) {
++ if (k < fromDay) continue;
++ if (k > toDay) break;
++ if (exdates.dates.has(k)) continue;
++ out.push({ date: k });
++ } else {
++ if (at < from) continue;
++ if (at >= to) break;
++ if (exdates.instants.has(at)) continue;
++ out.push({ start: at, end: at + plan.duration });
++ }
++ if (out.length >= MAX_EVENTS) break;
++ }
++ return out;
++}
++
++// ── Reading a calendar ─────────────────────────────────────────────────────────────────────────────────────────
++/**
++ * The events of one calendar in the window.
++ * from, to: epoch ms; tz: the reader's own zone (a time with no zone is read in it).
++ */
++function occurrences(body, { from = Date.now(), to = from + 86400000, tz = 'UTC' } = {}) {
++ const skipped = {};
++ const skip = (reason) => { skipped[reason] = (skipped[reason] || 0) + 1; };
++ const readerZone = when.validTz(tz) ? tz : 'UTC';
++ const vevents = components(unfold(body)).filter((c) => c.type === 'VEVENT');
++
++ // The window in the reader's own dates: an all-day event is a day, not an instant.
++ const window = {
++ from, to,
++ fromDay: keyOf(when.partsIn(new Date(from), readerZone)),
++ toDay: keyOf(when.partsIn(new Date(to - 1), readerZone)),
++ };
++
++ // By UID: the series (a master) and the instances that move it.
++ const masters = new Map();
++ const overrides = [];
++ const taken = new Map(); // uid → the keys an instance takes away from the series
++ for (const c of vevents) {
++ const map = propsOf(c);
++ const uid = text(one(map, 'UID') ? one(map, 'UID').value : '') || null;
++ const rid = readDate(one(map, 'RECURRENCE-ID'));
++ if (rid) {
++ overrides.push({ map, uid });
++ if (uid) {
++ let key = null;
++ if (rid.kind === 'date') key = 'd:' + keyOf(rid);
++ else {
++ const z = zoneOf(rid, readerZone);
++ if (z) key = 'i:' + instantOf(rid, z);
++ }
++ if (key) {
++ if (!taken.has(uid)) taken.set(uid, new Set());
++ taken.get(uid).add(key);
++ }
++ }
++ continue;
++ }
++ if (!uid) { skip('no-uid'); continue; }
++ const seen = masters.get(uid);
++ const seq = parseInt(String(one(map, 'SEQUENCE') ? one(map, 'SEQUENCE').value : '0'), 10) || 0;
++ if (!seen || seq >= seen.seq) masters.set(uid, { map, seq });
++ }
++
++ const events = [];
++ const read = (entry, single) => {
++ const map = entry.map;
++ const status = String(one(map, 'STATUS') ? one(map, 'STATUS').value : '').trim().toUpperCase();
++ if (status === 'CANCELLED') { skip('cancelled'); return; }
++ const dtstart = readDate(one(map, 'DTSTART'));
++ if (!dtstart) { skip('no-start'); return; }
++ const eventZone = zoneOf(dtstart, readerZone);
++ if (!eventZone) { skip('unknown-zone'); return; }
++ const uid = text(one(map, 'UID') ? one(map, 'UID').value : '') || null;
++
++ // An instance that moves a series is one event; a series is read through its rule.
++ const rruleProp = single ? null : one(map, 'RRULE');
++ const rule = rruleProp ? readRRule(rruleProp) : null;
++ if (rule && rule.error) { skip(rule.error); return; }
++
++ // Where the first occurrence is, and how long it is.
++ const allDay = dtstart.kind === 'date';
++ const dtend = readDate(one(map, 'DTEND'));
++ const durationProp = readDuration(one(map, 'DURATION'));
++ let duration;
++ if (dtend) {
++ if (allDay || dtend.kind === 'date') {
++ const a = Date.UTC(dtstart.y, dtstart.m - 1, dtstart.d);
++ const b = Date.UTC(dtend.y, dtend.m - 1, dtend.d);
++ duration = Math.max(b - a, 86400000);
++ } else {
++ const a = instantOf(dtstart, eventZone);
++ const b = instantOf(dtend, zoneOf(dtend, readerZone) || eventZone);
++ if (a == null || b == null || b < a) { skip('bad-end'); return; }
++ duration = b - a;
++ }
++ } else if (durationProp) {
++ duration = durationProp;
++ } else {
++ duration = allDay ? 86400000 : 3600000;
++ }
++
++ const wall = { y: dtstart.y, m: dtstart.m, d: dtstart.d, h: dtstart.h, min: dtstart.min, s: dtstart.s };
++ const plan = {
++ allDay, start: wall, zone: eventZone, duration,
++ first: allDay ? null : instantOf(wall, eventZone),
++ };
++
++ // The days this event is not on.
++ const exdates = { instants: new Set(), dates: new Set() };
++ for (const p of (map.get('EXDATE') || [])) {
++ for (const piece of String(p.value || '').split(',')) {
++ const d = readDate({ value: piece.trim(), params: p.params });
++ if (!d) continue;
++ if (d.kind === 'date') exdates.dates.add(keyOf(d));
++ else {
++ const z = zoneOf(d, readerZone);
++ if (z) exdates.instants.add(instantOf(d, z));
++ }
++ }
++ }
++
++ const title = text(one(map, 'SUMMARY') ? one(map, 'SUMMARY').value : '') || '(no title)';
++ const gone = uid && taken.get(uid) ? taken.get(uid) : null;
++ for (const o of expand(plan, rule && rule.rule, exdates, window)) {
++ if (gone) {
++ const key = o.date ? 'd:' + o.date : 'i:' + o.start;
++ if (gone.has(key)) continue;
++ }
++ events.push({
++ uid,
++ title,
++ allDay: !!o.date,
++ date: o.date ? isoDay(o.date) : null,
++ start: o.start == null ? null : o.start,
++ end: o.end == null ? null : o.end,
++ tz: allDay ? null : eventZone,
++ });
++ }
++ };
++
++ for (const entry of masters.values()) read(entry, false);
++ for (const entry of overrides) read(entry, true);
++
++ events.sort((a, b) => (a.allDay !== b.allDay ? (a.allDay ? -1 : 1) : ((a.start || 0) - (b.start || 0))));
++ return { events: events.slice(0, MAX_EVENTS), skipped };
++}
++
++module.exports = { occurrences, unfold, parseLine, components, readDate, readRRule, readDuration, text };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex eac4fc8..5aa774c 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -24,6 +24,9 @@ const TABLES = [
// The public Live username → Network subject map the import job uses to put a followed channel first. It is a
// public channel fact, not a private one, so it is erased with the person and not exported.
{ table: 'today_channels', subject: 'subject', value: (usr) => usr, file: null, erase: 'delete' },
++ // The address of a calendar a person imported is theirs alone: it can carry a secret, so it is erased with the
++ // account and never exported.
++ { table: 'today_calendar_imports', subject: 'subject', value: (usr) => usr, file: null, erase: 'delete' },
];
async function extraErase(t) {
ddiff --git a/server/net/calendar-fetch.js b/server/net/calendar-fetch.js
nnew file mode 100644
iindex 0000000..923e801
---- /dev/null
++++ b/server/net/calendar-fetch.js
@@@ -0,0 +1,137 @@
++'use strict';
++/**
++ * Reading a calendar a person chose (plan L48 R3). The address is theirs, so it is read through the one rule for
++ * connecting to an address someone else chose (openvibe-shared/egress): https only, a public host only, a timeout
++ * and a body cap. A name that resolves to a private range or to a link-local address is refused before a byte is
++ * sent, and the check runs again at connect time (the safe lookup), so a name that answers 127.0.0.1 a moment
++ * later is refused too.
++ *
++ * A redirect is followed, because some calendars answer 301 to their real address, but only after the same check:
++ * a redirect can never land on a private address. At most three hops.
++ *
++ * The answer must be a calendar: text/calendar or text/plain, at most 2 MB, within 10 seconds.
++ *
++ * fetchCalendar(url, { timeoutMs, maxBytes }) → { status, headers, body, type }
++ * assertReachable(url) → the URL, once it may be read
++ * checkUrl(url) → the URL, once its shape is allowed
++ */
++const https = require('https');
++const net = require('net');
++const egress = require('openvibe-shared/egress');
++
++const DEFAULT_TIMEOUT_MS = 10_000;
++const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
++const ALLOWED_PORTS = ['443', '8443'];
++const ALLOWED_TYPES = ['text/calendar', 'text/plain'];
++const MAX_REDIRECTS = 3;
++
++class GuardedError extends Error {
++ constructor(message, code = 'today.calendar.refused') {
++ super(message);
++ this.name = 'GuardedError';
++ this.code = code;
++ }
++}
++
++/** The URL, once its shape is allowed: https, no credentials in it, a public host, a known port. */
++function checkUrl(url, { ports = ALLOWED_PORTS, isAllowed = egress.isPublicAddress } = {}) {
++ let u;
++ try { u = url instanceof URL ? url : new URL(String(url)); } catch { throw new GuardedError('that is not an address'); }
++ if (u.protocol !== 'https:') throw new GuardedError('use an https address');
++ if (u.username || u.password) throw new GuardedError('an address with a password in it is not allowed');
++ const port = u.port || '443';
++ if (!ports.includes(port)) throw new GuardedError('port ' + port + ' is not allowed');
++ const host = egress.normalizeHost(u.hostname);
++ if (egress.isInternalName(host)) throw new GuardedError(host + ' is not a public host name');
++ // Node does not look up an address that is already an address, so it is checked here.
++ if (net.isIP(host) && !isAllowed(host)) throw new GuardedError(host + ' is not a public address');
++ return u;
++}
++
++/**
++ * The URL, once it may be read: the shape is allowed, and every address the name resolves to is public. A name
++ * that answers a private address is refused here, before the connection.
++ */
++async function assertReachable(url, { lookup, isAllowed = egress.isPublicAddress, ports } = {}) {
++ const u = checkUrl(url, { ports, isAllowed });
++ const look = egress.createSafeLookup({ lookup, isAllowed });
++ await new Promise((resolve, reject) => {
++ look(u.hostname, { all: true }, (err) => {
++ if (!err) return resolve();
++ return reject(err && err.code === 'EGRESS_DENIED' ? new GuardedError(err.message) : err);
++ });
++ });
++ return u;
++}
++
++/** One request, through the egress guard. The body is read up to maxBytes; a bigger answer is refused. */
++function requestOnce(url, { timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_BYTES, lookup, isAllowed, headers = {} } = {}) {
++ const u = url instanceof URL ? url : new URL(String(url));
++ const safeLookup = egress.createSafeLookup({ lookup, isAllowed });
++ return new Promise((resolve, reject) => {
++ let done = false;
++ const finish = (err, value) => { if (done) return; done = true; if (err) reject(err); else resolve(value); };
++ const req = https.request({
++ hostname: u.hostname,
++ port: u.port || 443,
++ path: u.pathname + u.search,
++ method: 'GET',
++ servername: u.hostname,
++ lookup: safeLookup,
++ headers: {
++ accept: 'text/calendar, text/plain;q=0.9',
++ 'user-agent': 'OpenVibe.Today (+https://openvibe.today)',
++ ...headers,
++ },
++ }, (res) => {
++ const chunks = [];
++ let size = 0;
++ res.on('data', (chunk) => {
++ size += chunk.length;
++ if (size > maxBytes) {
++ req.destroy(new GuardedError('the answer is bigger than ' + maxBytes + ' bytes', 'today.calendar.too_large'));
++ return;
++ }
++ chunks.push(chunk);
++ });
++ res.on('end', () => finish(null, { status: res.statusCode || 0, headers: res.headers || {}, body: Buffer.concat(chunks) }));
++ res.on('error', (err) => finish(err));
++ });
++ req.setTimeout(timeoutMs, () => req.destroy(new GuardedError('the address did not answer within ' + timeoutMs + ' ms', 'today.calendar.timeout')));
++ req.on('error', (err) => finish(err && err.code === 'EGRESS_DENIED' ? new GuardedError(err.message) : err));
++ req.end();
++ });
++}
++
++/** The calendar at that address. */
++async function fetchCalendar(url, opts = {}) {
++ const maxBytes = opts.maxBytes || DEFAULT_MAX_BYTES;
++ const send = opts.request || requestOnce;
++ let current = String(url);
++ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
++ await assertReachable(current, opts);
++ const res = await send(current, { ...opts, maxBytes });
++ const status = res.status;
++ if (status >= 300 && status < 400 && res.headers.location) {
++ let next;
++ try { next = new URL(String(res.headers.location), current).toString(); } catch {
++ throw new GuardedError('the address answers a redirect to nowhere', 'today.calendar.bad_redirect');
++ }
++ current = next; // the next round checks the new address the same way
++ continue;
++ }
++ if (status < 200 || status >= 300) throw new GuardedError('the address answered ' + status, 'today.calendar.http_error');
++ if (res.body.length > maxBytes) throw new GuardedError('the answer is bigger than ' + maxBytes + ' bytes', 'today.calendar.too_large');
++ const type = String(res.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
++ if (!ALLOWED_TYPES.includes(type)) {
++ throw new GuardedError('that address did not answer a calendar (it said ' + (type || 'nothing') + ')', 'today.calendar.not_a_calendar');
++ }
++ return { status, headers: res.headers, body: res.body.toString('utf8'), type };
++ }
++ throw new GuardedError('the address redirects too many times', 'today.calendar.too_many_redirects');
++}
++
++module.exports = {
++ fetchCalendar, assertReachable, checkUrl, requestOnce, GuardedError,
++ DEFAULT_TIMEOUT_MS, DEFAULT_MAX_BYTES, ALLOWED_PORTS, ALLOWED_TYPES, MAX_REDIRECTS,
++};
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 0a67e55..6c4d9cb 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -7,8 +7,8 @@
* home({ upcoming, now }) the front door: the day's ring, what is coming, three scenes
* channel({ username, rows, now }) a channel's schedule and its calendar
* event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)
-- * me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders and private feed
-- * week({ name, week }) the person's week, day by day (server/week.js, plan L48 R3)
++ * me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders, calendars and private feed
++ * week({ name, week, problems }) the person's week, day by day (server/week.js, plan L48 R3)
* brief({ out, name, tz }) the day's brief
* schedule({ values, problem, zones }) the schedule form
* signIn({ title, text, next }) a page that needs an account
@@@ -248,13 +248,39 @@ ${done.length ? `<details class="td-mine-done"><summary>Done (${done.length})</s
</section>`;
}
--function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now(), mine = [], problem = null, said = '' } = {}) {
++/**
++ * The calendars a person imports (plan L48 R3): a small form under the reminders, and the list with each address
++ * shortened and a button to take it away.
++ */
++function calendars({ list = [], problem = null, said = '' } = {}) {
++ const row = (c) => `<li class="td-mine-row">
++<span class="td-mine-ico" aria-hidden="true">${kit.icon('calendar')}</span>
++<span class="td-mine-main"><b>${esc(c.name)}</b><small title="${esc(c.url)}">${esc(c.short)}</small>${c.last_error ? `<small class="is-bad">Could not be read: ${esc(c.last_error)}</small>` : ''}</span>
++<span class="td-mine-acts"><form method="post" action="/me/calendars/${esc(c.id)}/remove" class="td-inline"><button class="td-chip-btn" type="submit">Remove</button></form></span>
++</li>`;
++ return `<section class="td-card td-remind" id="calendars" aria-labelledby="td-cal-h">
++<h2 id="td-cal-h">${kit.icon('calendar')}Import a calendar</h2>
++<p class="td-fine">The address of any calendar that reads iCalendar (.ics). Its events show on your week, and Today reads it at most once every half hour.</p>
++<form method="post" action="/me/calendars" class="td-cal-form">
++<label class="td-sr" for="td-cal-url">The address of the calendar</label>
++<input id="td-cal-url" name="url" type="text" required maxlength="500" autocomplete="off" value="${esc(said)}" placeholder="https://…/calendar.ics">
++<label class="td-sr" for="td-cal-name">A name for it (optional)</label>
++<input id="td-cal-name" name="name" type="text" maxlength="60" autocomplete="off" placeholder="Work">
++<button class="ovk-btn ovk-btn--primary" type="submit">${kit.icon('plus')}Add</button>
++</form>
++${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}
++${list.length ? `<ul class="td-mine">${list.map(row).join('')}</ul>` : '<p class="td-fine">None yet. A calendar you add here shows on your week, beside your own events.</p>'}
++</section>`;
++}
++
++function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now(), mine = [], myCalendars = [], problem = null, said = '', calProblem = null, calSaid = '' } = {}) {
const webcal = String(feedUrl).replace(/^https?:/, 'webcal:');
return `<div class="td-wrap td-page-pad" id="td-me">
<header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1>
<p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p>
<div class="td-acts">${kit.button({ href: '/me/week', label: 'Your week', kind: 'primary' })}${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'quiet' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>
${myReminders({ mine, problem, said })}
++${calendars({ list: myCalendars, problem: calProblem, said: calSaid })}
${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}
<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div>
<aside class="td-side">
@@@ -273,15 +299,17 @@ ${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/
/**
* The person's week (plan L48 R3): seven days from Monday in their own time zone. On a computer, seven columns; on a
* phone, the days one under another, with the empty ones said in one line. Each line is a time, what it is, and a
-- * mark for its kind: a reminder (once or repeating), a repeating reminder's span for the day, their own event, or a
-- * stream of a channel they follow.
++ * mark for its kind: a reminder (once or repeating), a repeating reminder's span for the day, their own event, a
++ * stream of a channel they follow, or an event of a calendar they imported.
*/
--function week({ name, week: w }) {
-- const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'] };
++function week({ name, week: w, problems = [] }) {
++ const KIND = { reminder: ['bell', 'Reminder'], band: ['retry', 'Repeats'], event: ['calendar', 'Your event'], stream: ['live', 'Stream'], import: ['calendar', 'Calendar'] };
const item = (it) => {
const [icon, word] = KIND[it.kind] || KIND.reminder;
-- const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;
-- return `<li class="td-wi is-${esc(it.kind)}${it.live ? ' is-live' : ''}"><a href="${esc(it.href)}"><span class="td-wi-k" aria-hidden="true">${kit.icon(icon)}</span><time datetime="${esc(it.at)}">${esc(it.time)}</time><span class="td-wi-t">${esc(it.title)}</span><span class="td-wi-m">${it.live ? '<span class="td-pill is-live">Live now</span>' : ''}<span class="ovk-sr">${esc(word)}: </span>${esc(meta)}</span></a></li>`;
++ const meta = it.kind === 'band' ? it.summary : it.kind === 'stream' ? (it.channel ? `@${it.channel}` : word) : it.kind === 'import' ? (it.calendar || word) : it.kind === 'reminder' && it.repeats ? 'Repeats' : word;
++ const body = `<span class="td-wi-k" aria-hidden="true">${kit.icon(icon)}</span><time datetime="${esc(it.at)}">${esc(it.time)}</time><span class="td-wi-t">${esc(it.title)}</span><span class="td-wi-m">${it.live ? '<span class="td-pill is-live">Live now</span>' : ''}<span class="ovk-sr">${esc(word)}: </span>${esc(meta)}</span>`;
++ // An imported event has no page of its own here: it is shown, not linked.
++ return `<li class="td-wi is-${esc(it.kind)}${it.live ? ' is-live' : ''}">${it.href ? `<a href="${esc(it.href)}">${body}</a>` : `<a>${body}</a>`}</li>`;
};
// On a phone, a run of empty days is one line ("Nothing on Sat and Sun").
const runs = [];
@@@ -304,7 +332,9 @@ ${d.items.length ? `<ul class="td-wis">${d.items.map(item).join('')}</ul>` : '<p
<form class="td-quick" method="post" action="/me/reminders"><input type="hidden" name="back" value="week"><input type="hidden" name="tz" value="${esc(w.tz)}" data-tz>
<label class="ovk-sr" for="td-quick-text">Add a reminder in plain words</label><input id="td-quick-text" name="text" maxlength="500" required placeholder="Remind me… every weekday at 9, tomorrow at 3pm, every 2 hours from 9 to 5" autocomplete="off">
<button class="ovk-btn ovk-btn--primary ovk-btn--small" type="submit">${kit.icon('plus')}Add</button></form>
++<p class="td-fine">The calendars you import show here too. <a href="/me#calendars">Add or remove one</a>.</p>
</header>
++${problems.length ? `<p class="td-note is-bad" role="status">${problems.map((p) => esc(p.name) + ': ' + esc(p.message)).join(' — ')}</p>` : ''}
<ol class="td-week" aria-label="The week">${cols}</ol>
<ol class="td-pweek" aria-label="The week, day by day">${phone}</ol>
</div>`;
ddiff --git a/server/week.js b/server/week.js
iindex 7e518fc..0fe4c39 100644
---- a/server/week.js
++++ b/server/week.js
@@@ -4,10 +4,11 @@
* from a Monday, in the person's own time zone, each with what happens in it, in time order:
* - their own reminders (server/my-reminders.js): each time a rule fires that week, except a rule that fires every
* few minutes or hours (interval), which is one line a day with its span ("Every 2 hours, 9:00–17:00");
-- * - their events, the events they set a reminder on, and the events of the channels they follow (server/agenda.js).
++ * - their events, the events they set a reminder on, and the events of the channels they follow (server/agenda.js);
++ * - the events of the calendars they imported (server/calendar-imports.js), each with the calendar's short name.
*
-- * build({ tz, start, now, reminders, events }) → { tz, start, prev, next, thisWeek, days: [{ key, label, date, today,
-- * items: [{ kind, at, time, title, href, … }] }] }
++ * build({ tz, start, now, reminders, events, imports }) → { tz, start, prev, next, thisWeek, days: [{ key, label,
++ * date, today, items: [{ kind, at, time, title, href, … }] }] }
* Pure: the callers fetch, this arranges. tz is an IANA zone (UTC when unknown); start is a YYYY-MM-DD (any day of the
* week it is in) or null for this week.
*/
@@@ -25,7 +26,7 @@ function addDays(y, m, d, n) {
}
const clock = (p) => `${p.h}:${pad(p.min)}`;
--function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [] } = {}) {
++function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], events = [], imports = [] } = {}) {
const zone = when.validTz(tz) ? tz : 'UTC';
const today = when.partsIn(new Date(now), zone);
// The Monday of the week asked for (or this one).
@@@ -78,7 +79,30 @@ function build({ tz = 'UTC', start = null, now = Date.now(), reminders = [], eve
if (!day) continue;
day.items.push({ kind: e.own ? 'event' : 'stream', at: new Date(at).toISOString(), time: clock(when.partsIn(new Date(at), zone)), title: e.title, channel: e.channel || null, live: e.status === 'live', reminded: !!e.reminded, href: `/e/${e.id}`, id: e.id });
}
-- days.forEach((d) => d.items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)));
++ // The calendars they imported: their own kind, in the person's zone. An all-day event sits at the top of its day.
++ for (const it of imports || []) {
++ if (!it) continue;
++ let day = null;
++ let at = null;
++ let time = null;
++ if (it.allDay) {
++ const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(it.date || ''));
++ if (!d) continue;
++ day = days.find((x) => x.key === it.date) || null;
++ if (!day) continue;
++ at = when.atLocal(+d[1], +d[2], +d[3], 0, 0, zone).toISOString();
++ time = 'All day';
++ } else {
++ const ms = Number.isFinite(it.start) ? it.start : Date.parse(it.at || '');
++ if (!Number.isFinite(ms)) continue;
++ day = dayOf(ms);
++ if (!day) continue;
++ at = new Date(ms).toISOString();
++ time = clock(when.partsIn(new Date(ms), zone));
++ }
++ day.items.push({ kind: 'import', at, time, allDay: !!it.allDay, title: it.title || 'An event', calendar: it.calendar || null, href: null });
++ }
++ days.forEach((d) => d.items.sort((a, b) => (!!a.allDay !== !!b.allDay ? (a.allDay ? -1 : 1) : (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))));
const prev = addDays(mon.y, mon.m, mon.d, -7), next = addDays(mon.y, mon.m, mon.d, 7);
const thisMon = addDays(today.y, today.m, today.d, -((today.dow || 1) - 1));
return {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 917344b..e508a5f 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -72,6 +72,12 @@ async function startNetworkStub() {
await db.exec(`INSERT INTO today_briefs (subject, day, body, created_at) VALUES ($1, '2026-10-28'::date, '[]'::jsonb, now()), ($2, '2026-10-28'::date, '[]'::jsonb, now())`, [A, B]);
await db.exec(`INSERT INTO today_idempotency (subject, key, event_id, created_at) VALUES ($1, 'a-key', 'tev_01JZ000000000000000000AA', now())`, [A]);
await db.exec(`INSERT INTO today_outbox (event_id, topic, payload, created_at) VALUES ('evt_01JZ0000000000000000000A1', 'today.event.scheduled', '{"event_id":"tev_01JZ000000000000000000AA"}'::jsonb, now())`);
++ // The address of a calendar A imported: theirs alone, so it is never exported.
++ await db.exec(`INSERT INTO today_calendar_imports (id, subject, url, name, created_at) VALUES
++ ('cal_01JZ00000000000000000AA', $1, 'https://secret.example.com/a.ics', 'A calendar', now()),
++ ('cal_01JZ00000000000000000BB', $2, 'https://secret.example.com/b.ics', 'B calendar', now())`, [A, B]);
++ await db.exec(`INSERT INTO today_calendar_import_cache (import_id, body, fetched_at) VALUES
++ ('cal_01JZ00000000000000000AA', 'BEGIN:VCALENDAR', now())`);
await check('an export carries only A\'s rows, and never the feed token', async () => {
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
@@@ -87,6 +93,7 @@ async function startNetworkStub() {
assert.ok(!all.includes('B stream'), 'nobody else\'s event');
assert.ok(!all.includes('feedtokenA_fixture'), 'the feed token is never exported');
assert.ok(!all.includes('feedtokenB_fixture'));
++ assert.ok(!all.includes('secret.example.com'), 'the address of an imported calendar is never exported');
const events = part.body.files.find((f) => f.name === 'events.json').content;
assert.deepStrictEqual(events.map((e) => e.id), ['tev_01JZ000000000000000000AA']);
const reminders = part.body.files.find((f) => f.name === 'reminders.json').content;
@@@ -103,6 +110,9 @@ async function startNetworkStub() {
assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_feeds WHERE subject = $1', [A]), 0);
assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_briefs WHERE subject = $1', [A]), 0);
assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_idempotency WHERE subject = $1', [A]), 0);
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_calendar_imports WHERE subject = $1', [A]), 0, 'the imported calendar goes with the account');
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_calendar_import_cache WHERE import_id = $1', ['cal_01JZ00000000000000000AA']), 0, 'and the copy of it');
++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_calendar_imports WHERE subject = $1', [B]), 1, 'B\'s calendar stays');
assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [B]), 1, 'B\'s event stays');
assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE subject = $1', [B]), 1, 'B\'s own reminder stays');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
ddiff --git a/test/calendar-imports.test.js b/test/calendar-imports.test.js
nnew file mode 100644
iindex 0000000..21b978c
---- /dev/null
++++ b/test/calendar-imports.test.js
@@@ -0,0 +1,218 @@
++'use strict';
++/**
++ * The calendars a person imports (plan L48 R3: "import any calendar by ICS address"). Reading a calendar: a time in
++ * a zone, an all-day event, a weekly rule with BYDAY, an excluded day, a cancelled event, a rule that is too clever,
++ * and a clock change. Reading it safely: a private address refused, a redirect to a private address refused, a body
++ * too big, an answer that is not a calendar. Then the week that shows it, the five addresses a person may keep, the
++ * half-hour between two reads, and a form post from another site refused.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const ical = require('../server/ical');
++const week = require('../server/week');
++const net = require('../server/net/calendar-fetch');
++const imports = require('../server/calendar-imports');
++
++const cal = (...lines) => ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//OpenVibe//Today//EN', ...lines, 'END:VCALENDAR'].join('\r\n');
++const vevent = (...lines) => ['BEGIN:VEVENT', ...lines, 'END:VEVENT'];
++const paris = (iso) => new Date(iso).toLocaleString('en-GB', { timeZone: 'Europe/Paris' });
++// A refusal: the shared egress rule says no, or the guard says no.
++const refused = (err) => err && (err.code === 'EGRESS_DENIED' || err.code === 'today.calendar.refused' || err.name === 'GuardedError');
++
++// A name that answers a public address, and one that answers a private one.
++const publicLookup = (host, opts, cb) => cb(null, [{ address: '93.184.216.34', family: 4 }]);
++const privateLookup = (host, opts, cb) => cb(null, [{ address: '10.0.0.5', family: 4 }]);
++
++(async () => {
++ await check('the address a person may add', () => {
++ assert.strictEqual(imports.normalizeUrl('webcal://calendar.example.com/work.ics'), 'https://calendar.example.com/work.ics', 'webcal becomes https');
++ assert.strictEqual(imports.normalizeUrl('https://calendar.example.com/work.ics'), 'https://calendar.example.com/work.ics');
++ assert.strictEqual(imports.normalizeUrl('http://calendar.example.com/work.ics'), null, 'https only');
++ assert.strictEqual(imports.normalizeUrl('javascript:alert(1)'), null);
++ assert.strictEqual(imports.normalizeUrl('https://user:[email protected]/x.ics'), null, 'no credentials in an address');
++ assert.strictEqual(imports.normalizeUrl(' '), null);
++ assert.strictEqual(imports.normalizeUrl('https://localhost/cal.ics'), null, 'not this machine');
++ assert.strictEqual(imports.shortName('https://calendar.google.com/x.ics', ''), 'Calendar', 'the host says who it is');
++ assert.strictEqual(imports.shortName('https://calendar.google.com/x.ics', 'Work'), 'Work', 'or the name the person gave');
++ assert.strictEqual(imports.shortAddress('https://calendar.google.com/private/secret-token.ics'), 'calendar.google.com', 'a path that holds a secret is left out');
++ assert.strictEqual(imports.shortAddress('https://example.com/cal.ics'), 'example.com/cal.ics');
++ assert.strictEqual(imports.MAX_PER_PERSON, 5);
++ });
++
++ await check('reading a calendar: a time in a zone, an all-day event, a weekly rule, an excluded day, a cancelled event', () => {
++ const ics = cal(
++ ...vevent('UID:1', 'SUMMARY:Berlin standup', 'DTSTART;TZID=Europe/Berlin:20261104T090000', 'DTEND;TZID=Europe/Berlin:20261104T093000'),
++ ...vevent('UID:2', 'SUMMARY:Company day', 'DTSTART;VALUE=DATE:20261105'),
++ ...vevent('UID:3', 'SUMMARY:Review', 'DTSTART;TZID=Europe/Paris:20261102T170000', 'RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=6', 'EXDATE;TZID=Europe/Paris:20261104T170000'),
++ ...vevent('UID:4', 'SUMMARY:Off', 'DTSTART:20261103T100000Z', 'STATUS:CANCELLED'),
++ );
++ const out = ical.occurrences(ics, { from: Date.parse('2026-11-01T00:00:00Z'), to: Date.parse('2026-12-01T00:00:00Z'), tz: 'Europe/Paris' });
++ const standup = out.events.find((e) => e.title === 'Berlin standup');
++ assert.strictEqual(standup.start, Date.parse('2026-11-04T08:00:00Z'), 'nine in the morning in Berlin');
++ assert.strictEqual(standup.tz, 'Europe/Berlin');
++ const allDay = out.events.find((e) => e.title === 'Company day');
++ assert.strictEqual(allDay.allDay, true);
++ assert.strictEqual(allDay.date, '2026-11-05', 'an all-day event is a day, not an instant');
++ assert.strictEqual(allDay.start, null);
++ const review = out.events.filter((e) => e.title === 'Review');
++ assert.deepStrictEqual(review.map((e) => paris(e.start).slice(0, 10)), ['02/11/2026', '09/11/2026', '11/11/2026', '16/11/2026', '18/11/2026'],
++ 'Mondays and Wednesdays, six of them, minus the excluded Wednesday');
++ assert.ok(!out.events.some((e) => e.title === 'Off'), 'a cancelled event is not shown');
++ assert.strictEqual(out.skipped.cancelled, 1);
++ });
++
++ await check('a rule ends at its UNTIL', () => {
++ const allDay = cal(...vevent('UID:1', 'SUMMARY:Every day', 'DTSTART;VALUE=DATE:20261101', 'RRULE:FREQ=DAILY;UNTIL=20261104'));
++ const out = ical.occurrences(allDay, { from: Date.parse('2026-11-01T00:00:00Z'), to: Date.parse('2026-12-01T00:00:00Z'), tz: 'UTC' });
++ assert.deepStrictEqual(out.events.map((e) => e.date), ['2026-11-01', '2026-11-02', '2026-11-03', '2026-11-04'], 'the fourth day is the last');
++ const timed = cal(...vevent('UID:2', 'SUMMARY:Until', 'DTSTART;TZID=Europe/Paris:20261101T090000', 'RRULE:FREQ=DAILY;UNTIL=20261104T000000Z'));
++ const out2 = ical.occurrences(timed, { from: Date.parse('2026-11-01T00:00:00Z'), to: Date.parse('2026-12-01T00:00:00Z'), tz: 'Europe/Paris' });
++ assert.strictEqual(out2.events.length, 3, 'the fourth morning is past the UNTIL');
++ });
++
++ await check('a rule that is too clever is left out, not guessed at', () => {
++ const ics = cal(
++ ...vevent('UID:1', 'SUMMARY:Second Friday', 'DTSTART;TZID=Europe/Paris:20261101T090000', 'RRULE:FREQ=MONTHLY;BYDAY=2FR'),
++ ...vevent('UID:2', 'SUMMARY:With a month', 'DTSTART;TZID=Europe/Paris:20261101T090000', 'RRULE:FREQ=MONTHLY;BYMONTH=11'),
++ ...vevent('UID:3', 'SUMMARY:Ordinary', 'DTSTART:20261104T090000Z'),
++ );
++ const out = ical.occurrences(ics, { from: Date.parse('2026-11-01T00:00:00Z'), to: Date.parse('2026-12-01T00:00:00Z'), tz: 'Europe/Paris' });
++ assert.deepStrictEqual(out.events.map((e) => e.title), ['Ordinary']);
++ assert.strictEqual(out.skipped['rule:byday-ordinal'], 1, 'the second Friday is not read');
++ assert.strictEqual(out.skipped['rule:bymonth'], 1, 'a month in the rule is not read');
++ });
++
++ await check('across a clock change the event keeps its hour', () => {
++ const ics = cal(...vevent('UID:1', 'SUMMARY:Morning', 'DTSTART;TZID=America/New_York:20261030T090000', 'RRULE:FREQ=DAILY;COUNT=4'));
++ const out = ical.occurrences(ics, { from: Date.parse('2026-10-29T00:00:00Z'), to: Date.parse('2026-11-05T00:00:00Z'), tz: 'America/New_York' });
++ assert.deepStrictEqual(out.events.map((e) => new Date(e.start).toISOString()),
++ ['2026-10-30T13:00:00.000Z', '2026-10-31T13:00:00.000Z', '2026-11-01T14:00:00.000Z', '2026-11-02T14:00:00.000Z'],
++ 'nine in the morning every day, although the clock went back on 1 November');
++ });
++
++ await check('the text of an event is read with its escapes undone', () => {
++ const ics = cal(...vevent('UID:1', 'SUMMARY:Lunch\\, with Ana\\nsecond line', 'DTSTART:20261104T120000Z'));
++ const out = ical.occurrences(ics, { from: Date.parse('2026-11-04T00:00:00Z'), to: Date.parse('2026-11-05T00:00:00Z'), tz: 'UTC' });
++ assert.strictEqual(out.events[0].title, 'Lunch, with Ana second line');
++ });
++
++ await check('a private address is refused before a byte is sent', async () => {
++ for (const url of ['https://127.0.0.1/cal.ics', 'https://10.1.2.3/cal.ics', 'https://192.168.1.1/cal.ics', 'https://169.254.169.254/latest/meta-data', 'https://localhost/cal.ics']) {
++ await assert.rejects(() => net.assertReachable(url), refused, url);
++ }
++ await assert.rejects(() => net.assertReachable('https://calendar.example.com/cal.ics', { lookup: privateLookup }), refused);
++ });
++
++ await check('a redirect to a private address is refused, and so is a body that is too big', async () => {
++ await assert.rejects(() => net.fetchCalendar('https://calendar.example.com/cal.ics', {
++ lookup: (host, opts, cb) => cb(null, [{ address: host.includes('internal') ? '10.0.0.5' : '93.184.216.34', family: 4 }]),
++ request: async () => ({ status: 302, headers: { location: 'https://internal.example.com/cal.ics' }, body: Buffer.alloc(0) }),
++ }), refused, 'the second hop is checked the same way');
++ await assert.rejects(() => net.fetchCalendar('https://calendar.example.com/cal.ics', {
++ lookup: publicLookup,
++ request: async () => ({ status: 200, headers: { 'content-type': 'text/calendar' }, body: Buffer.alloc(3 * 1024 * 1024) }),
++ }), (err) => err.code === 'today.calendar.too_large', 'more than two megabytes is refused');
++ await assert.rejects(() => net.fetchCalendar('https://calendar.example.com/cal.ics', {
++ lookup: publicLookup,
++ request: async () => ({ status: 200, headers: { 'content-type': 'application/json' }, body: Buffer.from('{}') }),
++ }), (err) => err.code === 'today.calendar.not_a_calendar', 'only a calendar is read');
++ const good = await net.fetchCalendar('https://calendar.example.com/cal.ics', {
++ lookup: publicLookup,
++ request: async () => ({ status: 200, headers: { 'content-type': 'text/calendar; charset=utf-8' }, body: Buffer.from('BEGIN:VCALENDAR\r\nEND:VCALENDAR') }),
++ });
++ assert.ok(good.body.startsWith('BEGIN:VCALENDAR'));
++ });
++
++ await check('the week puts an all-day event at the top of its day', () => {
++ const w = week.build({
++ tz: 'Europe/Paris', now: Date.parse('2026-11-04T12:00:00Z'),
++ imports: [
++ { allDay: true, date: '2026-11-04', title: 'Company day', calendar: 'Work' },
++ { allDay: false, start: Date.parse('2026-11-04T09:00:00Z'), title: 'Review', calendar: 'Work' },
++ ],
++ });
++ const day = w.days.find((d) => d.key === '2026-11-04');
++ assert.deepStrictEqual(day.items.map((i) => i.kind + ' ' + i.time), ['import All day', 'import 10:00']);
++ assert.strictEqual(day.items[0].calendar, 'Work', 'the calendar\'s short name');
++ });
++
++ // ── The service ────────────────────────────────────────────────────────────
++ const ICS = cal(
++ ...vevent('UID:1', 'SUMMARY:Imported meeting', 'DTSTART:20261104T180000Z', 'DTEND:20261104T190000Z'),
++ ...vevent('UID:2', 'SUMMARY:Imported day', 'DTSTART;VALUE=DATE:20261104'),
++ );
++ let calls = 0;
++ const calendarFetch = async () => { calls++; return { status: 200, headers: { 'content-type': 'text/calendar' }, body: ICS }; };
++ let clock = Date.parse('2026-11-04T15:00:00Z');
++ const t = await boot({ now: () => clock, calendarFetch });
++ const ana = t.network.addUser('ana');
++ try {
++ await check('a person adds a calendar on their agenda, and it shows on their week', async () => {
++ const added = await t.get('/me/calendars', { as: ana, form: { url: 'webcal://calendar.example.com/work.ics', name: 'Work' } });
++ assert.strictEqual(added.status, 303);
++ assert.strictEqual(added.headers.get('location'), '/me#calendars');
++ const me = await t.get('/me', { as: ana });
++ assert.match(me.text, /id="calendars"/, 'the form is on the agenda');
++ assert.ok(me.text.includes('calendar.example.com/work.ics'), 'the address is shown, shortened');
++ assert.match(me.text, /action="\/me\/calendars\/[^"]+\/remove"/, 'and it can be taken away');
++ const page = await t.get('/me/week', { as: ana });
++ assert.match(page.text, /is-import/, 'the imported event is its own kind');
++ assert.ok(page.text.includes('Imported meeting'), 'the event shows');
++ assert.ok(page.text.includes('Imported day'));
++ assert.ok(page.text.includes('Work'), 'with the calendar\'s short name');
++ assert.ok(page.text.indexOf('All day') < page.text.indexOf('Imported meeting'), 'the all-day event is at the top of its day');
++ });
++
++ await check('the address is shown shortened', async () => {
++ const kai = t.network.addUser('kai');
++ await t.get('/me/calendars', { as: kai, form: { url: 'https://calendar.google.com/private/9f8e7d6c5b4a3210-secret.ics' } });
++ const page = await t.get('/me', { as: kai });
++ assert.ok(page.text.includes('>calendar.google.com</small>'), 'the host is shown, and the path that holds the secret is not');
++ });
++
++ await check('a calendar is read at most once every half hour', async () => {
++ assert.strictEqual(calls, 1, 'the first view read it');
++ await t.get('/me/week', { as: ana });
++ assert.strictEqual(calls, 1, 'a second view a moment later does not read it again');
++ clock += 31 * 60_000;
++ await t.get('/me/week', { as: ana });
++ assert.strictEqual(calls, 2, 'after half an hour it is read again');
++ clock += 31 * 60_000;
++ });
++
++ await check('a person keeps at most five addresses', async () => {
++ for (let i = 0; i < 4; i++) {
++ const res = await t.get('/me/calendars', { as: ana, form: { url: 'https://c' + i + '.example.com/cal.ics' } });
++ assert.strictEqual(res.status, 303);
++ }
++ const me = await t.get('/me', { as: ana });
++ assert.strictEqual((me.text.match(/action="\/me\/calendars\/[^"]+\/remove"/g) || []).length, 5, 'five now');
++ const sixth = await t.get('/me/calendars', { as: ana, form: { url: 'https://c9.example.com/cal.ics' } });
++ assert.match(sixth.headers.get('location'), /cal_problem=/, 'the sixth is refused');
++ const after = await t.get('/me', { as: ana });
++ assert.strictEqual((after.text.match(/action="\/me\/calendars\/[^"]+\/remove"/g) || []).length, 5, 'and it is not added');
++ });
++
++ await check('a form post from another site is refused', async () => {
++ const bad = await t.get('/me/calendars', { as: ana, form: { url: 'https://evil.example.com/cal.ics' }, origin: 'https://evil.example' });
++ assert.strictEqual(bad.status, 403);
++ const gone = await t.get('/me/calendars/cal_x/remove', { as: ana, method: 'POST', origin: 'https://evil.example' });
++ assert.strictEqual(gone.status, 403);
++ });
++
++ await check('an address that cannot be read keeps the copy that is there', async () => {
++ const t2 = await boot({ now: () => clock, calendarFetch: async () => { throw new Error('the address did not answer'); } });
++ const bob = t2.network.addUser('bob');
++ try {
++ await t2.get('/me/calendars', { as: bob, form: { url: 'https://broken.example.com/cal.ics', name: 'Broken' } });
++ const page = await t2.get('/me/week', { as: bob });
++ assert.strictEqual(page.status, 200, 'the week still shows');
++ assert.match(page.text, /the address did not answer/, 'and it says why');
++ } finally {
++ await t2.close();
++ }
++ });
++ } finally {
++ await done(t);
++ }
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 413d721..9b72b17 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, calendarFetch: opts.calendarFetch });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;