Files
hub/caldav.js
T
nmemmertandClaude Sonnet 5.5 f18d9dd7a9
build-container / build (push) Successful in 2m20s
Multiple people: an admin adds users and chooses which tabs each may use
- Accounts: the setup code (or an existing single-user install, migrated automatically) creates the admin;
  the admin adds people in Settings -> People and gives each a one-time invite code to enrol their own passkey
- Each person's tasks, NE Hub events and subscribe link, settings, calendar links, iCloud connection and Plex
  sign-in are private, stored in their own folder (/data/users/<id>/); Plex sign-in moved from browser cookies
  into that folder (encrypted), so it also follows them across devices
- Tab access is enforced by the server (403) and reflected in the app (nav, Home, Settings, search, /api/config
  hides addresses of tabs a person cannot use); browser storage is namespaced per person
- Admin tools: change tabs, invite a device, sign out everywhere (session epoch), turn off, make/remove admin,
  delete a person and their data; the last admin cannot be removed
- Sessions carry the person (uid.epoch.expiry.signature); old single-user cookies still work as the admin
- tools/users-test.js (83 checks: isolation, access limits, invites, sign-out/disable/delete, Plex and iCloud
  per person, migration) now runs in the Gitea workflow before the image is built

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:35:14 -04:00

159 lines
13 KiB
JavaScript

// iCloud calendar access over CalDAV, using an Apple ID + app-specific password (no app registration).
// Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords.
// Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const auth = require('./auth');
const users = require('./users');
const file = () => path.join(users.dir(), 'icloud.json'); // each person connects their own iCloud
const TEST = process.env.CALDAV_TEST === '1'; // lets the tests talk to a local stand-in server; never set in production
const BASE = TEST ? process.env.CALDAV_BASE : 'https://caldav.icloud.com';
// ---- Encrypted credential store
let _key; const key = () => _key || (_key = crypto.scryptSync(auth.secret, 'nehub-icloud', 32));
const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv);
const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); };
function load() { try { return unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data); } catch { return null; } }
function save(o) { fs.writeFileSync(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
function wipe() { try { fs.unlinkSync(file()); } catch {} }
// ---- HTTP + XML
const allowed = u => TEST ? ['localhost', '127.0.0.1'].includes(u.hostname) : u.protocol === 'https:' && (u.hostname === 'icloud.com' || u.hostname.endsWith('.icloud.com'));
async function dav(method, url, { user, pass, depth, body, headers = {} } = {}) {
let u = new URL(url);
for (let hop = 0; hop < 4; hop++) {
if (!allowed(u)) throw new Error('Refusing to contact ' + u.hostname);
const r = await fetch(u, { method, redirect: 'manual', signal: AbortSignal.timeout(20000), body,
headers: { Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'), ...(depth !== undefined ? { Depth: String(depth) } : {}),
...(body ? { 'Content-Type': 'application/xml; charset=utf-8' } : {}), ...headers } });
if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; }
return { status: r.status, text: await r.text(), headers: r.headers, url: u.toString() };
}
throw new Error('Too many redirects');
}
const decode = s => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, x) => x.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'))
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&#13;/g, '\r').replace(/&#10;/g, '\n').replace(/&amp;/g, '&');
const tag = (xml, name) => { const m = new RegExp(`<(?:\\w+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:\\w+:)?${name}>`, 'i').exec(xml); return m ? m[1] : null; };
const responses = xml => (xml.match(/<(?:\w+:)?response(?:\s[^>]*)?>[\s\S]*?<\/(?:\w+:)?response>/gi) || []);
const hrefOf = block => { const h = tag(block, 'href'); return h ? decode(h).trim() : null; };
const abs = (href, base) => new URL(href, base).toString();
const PROPFIND_PRINCIPAL = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>';
const PROPFIND_HOME = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>';
const PROPFIND_CALS = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:prop><d:displayname/><d:resourcetype/><c:supported-calendar-component-set/><a:calendar-color/></d:prop></d:propfind>';
const stamp = ms => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const REPORT = (from, to) => `<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${stamp(from)}" end="${stamp(to)}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>`;
const calId = href => 'icloud:' + crypto.createHash('sha1').update(href).digest('hex').slice(0, 10);
/** Which of the collections in a PROPFIND listing are calendars, and do they hold events and/or tasks? Tolerates every XML style seen in the wild. */
function parseCollections(xml, baseUrl) {
const found = [], seen = [], candidates = [];
for (const r of responses(xml)) {
const rt = tag(r, 'resourcetype') || '';
// <calendar xmlns="urn:ietf:params:xml:ns:caldav"/>, <C:calendar/>, <cal:calendar></cal:calendar> ... the lookahead keeps
// calendar-proxy-read, calendar-home-set etc. from matching.
const isCal = /<(?:[\w-]+:)?calendar(?=[\s/>])/i.test(rt);
const comps = [...r.matchAll(/<(?:[\w-]+:)?comp\b[^>]*?\bname\s*=\s*["']([A-Za-z]+)["']/gi)].map(m => m[1].toUpperCase());
seen.push(isCal ? `calendar[${comps.join('+') || '?'}]` : (rt.replace(/<(?:[\w-]+:)?([\w-]+)[^>]*>/g, '$1 ').trim() || 'other').slice(0, 30));
if (!isCal) continue;
const events = !comps.length || comps.includes('VEVENT'), todos = comps.includes('VTODO');
const href = abs(hrefOf(r), baseUrl);
const color = (tag(r, 'calendar-color') || '').trim().slice(0, 7);
const entry = { id: calId(href), href, name: decode(tag(r, 'displayname') || 'Calendar').trim() || 'Calendar', color: /^#[0-9a-f]{6}$/i.test(color) ? color : '#d9453a', enabled: true, events, todos };
candidates.push({ ...entry, events: true, todos: false });
if (events || todos) found.push(entry);
}
// Safety net: if every collection that is a calendar was filtered out, keep them all as event calendars rather than none.
const out = found.length ? found : candidates;
out.seen = seen; out.found = out;
return out;
}
/** Sign in, find the calendars, and remember everything (encrypted). Throws a readable message on failure. */
async function connect(appleId, password) {
const o = { user: String(appleId || '').trim(), pass: String(password || '').replace(/\s/g, '') };
if (!o.user || !o.pass) throw new Error('Enter your Apple ID and an app-specific password');
const a = await dav('PROPFIND', BASE + '/', { ...o, depth: 0, body: PROPFIND_PRINCIPAL });
if (a.status === 401 || a.status === 403) throw new Error('Apple rejected that Apple ID or app-specific password. It must be an app-specific password, not your normal one.');
if (a.status >= 400) throw new Error('iCloud answered with an error (' + a.status + ')');
const principalHref = hrefOf(tag(a.text, 'current-user-principal') || '');
if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)');
const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME });
const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || '');
if (!homeHref) throw new Error('Could not find the calendar home for this account');
const home = abs(homeHref, b.url);
const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS });
if (c.status >= 400) throw new Error(`iCloud answered ${c.status} when listing the calendars`);
const calendars = parseCollections(c.text, c.url);
if (!calendars.found.length) throw new Error(`Signed in, but no calendars were found (iCloud listed ${calendars.seen.length} item${calendars.seen.length === 1 ? '' : 's'}: ${calendars.seen.slice(0, 8).join(', ') || 'none'}). Make sure Calendar is turned on for this Apple ID in iCloud settings.`);
save({ user: o.user, pass: o.pass, calendars: calendars.found });
return status();
}
const status = () => { const s = load(); return s ? { connected: true, appleId: s.user, calendars: s.calendars.filter(c => c.events !== false).map(({ id, name, color, enabled }) => ({ id, name, color, enabled })), reminderLists: s.calendars.filter(c => c.todos).length } : { connected: false }; };
function toggle(id, enabled) { const s = load(); if (!s) throw new Error('Not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); c.enabled = !!enabled; save(s); return status(); }
const enabled = () => { const s = load(); return s ? s.calendars.filter(c => c.enabled && c.events !== false).map(({ id, name, color }) => ({ id, name, color })) : []; };
/** Task lists (VTODO collections) the account exposes. Apple hides upgraded Reminders lists from CalDAV, so this may be empty. */
const todoLists = () => { const s = load(); return s ? s.calendars.filter(c => c.todos).map(({ id, name, color }) => ({ id, name, color })) : []; };
const inside = (c, href) => { if (!String(href || '').startsWith(c.href.replace(/\/?$/, '/'))) throw new Error('That event is not in this calendar'); };
const cred = id => { const s = load(); if (!s) throw new Error('iCloud is not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); return { s, c, o: { user: s.user, pass: s.pass } }; };
/** Raw calendar entries overlapping the window: [{ calId, name, color, href, etag, ics }] */
async function listRaw(from, to) {
const s = load(); if (!s) return [];
const out = [];
await Promise.all(s.calendars.filter(c => c.enabled && c.events !== false).map(async c => {
const r = await dav('REPORT', c.href, { user: s.user, pass: s.pass, depth: 1, body: REPORT(from - 864e5, to + 864e5) });
if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.');
if (r.status >= 400) throw new Error(`${c.name}: iCloud answered ${r.status}`);
for (const blk of responses(r.text)) {
const data = tag(blk, 'calendar-data'); if (!data) continue;
out.push({ calId: c.id, name: c.name, color: c.color, href: abs(hrefOf(blk), r.url), etag: decode(tag(blk, 'getetag') || '').trim(), ics: decode(data) });
}
}));
return out;
}
const REPORT_TODO = '<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VTODO"/></c:comp-filter></c:filter></c:calendar-query>';
/** Raw task entries in one list: [{ href, etag, ics }] */
async function listTodoRaw(id) {
const { c, o } = cred(id);
const r = await dav('REPORT', c.href, { ...o, depth: 1, body: REPORT_TODO });
if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.');
if (r.status >= 400) throw new Error(`${c.name}: iCloud answered ${r.status}`);
return responses(r.text).map(blk => ({ href: abs(hrefOf(blk), r.url), etag: decode(tag(blk, 'getetag') || '').trim(), ics: decode(tag(blk, 'calendar-data') || '') })).filter(x => x.ics);
}
async function getResource(id, href) {
const { c, o } = cred(id); inside(c, href);
const r = await dav('GET', href, o);
if (r.status === 404) throw new Error('That event no longer exists');
if (r.status >= 400) throw new Error('iCloud answered ' + r.status);
return { ics: r.text, etag: r.headers.get('etag') || '' };
}
async function putEvent(id, uid, icsText, { href, etag } = {}) {
const { c, o } = cred(id);
if (href) inside(c, href);
const url = href || new URL(encodeURIComponent(String(uid).split('@')[0]) + '.ics', c.href.replace(/\/?$/, '/')).toString();
const r = await dav('PUT', url, { ...o, body: icsText, headers: { 'Content-Type': 'text/calendar; charset=utf-8', ...(href ? (etag ? { 'If-Match': etag } : {}) : { 'If-None-Match': '*' }) } });
if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.');
if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.');
if (r.status >= 400) throw new Error('iCloud refused the change (' + r.status + ')');
return { href: url, etag: r.headers.get('etag') || '' };
}
async function deleteEvent(id, href, etag) {
const { c, o } = cred(id); inside(c, href);
const r = await dav('DELETE', href, { ...o, headers: etag ? { 'If-Match': etag } : {} });
if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.');
if (r.status >= 400 && r.status !== 404) throw new Error('iCloud refused the delete (' + r.status + ')');
}
module.exports = { parseCollections, connect, status, toggle, enabled, todoLists, listTodoRaw, listRaw, getResource, putEvent, deleteEvent, wipe, isCal: id => /^icloud:[0-9a-f]{10}$/.test(id || '') };