// 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 DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const FILE = path.join(DATA_DIR, 'icloud.json');
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
const 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.mkdirSync(DATA_DIR, { recursive: true }); 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(//g, (_, x) => x.replace(/&/g, '&').replace(//g, '>'))
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/
/g, '\r').replace(/
/g, '\n').replace(/&/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 = '';
const PROPFIND_HOME = '';
const PROPFIND_CALS = '';
const stamp = ms => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
const REPORT = (from, to) => `
`;
const calId = href => 'icloud:' + crypto.createHash('sha1').update(href).digest('hex').slice(0, 10);
/** 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 });
const calendars = [];
for (const r of responses(c.text)) {
const rt = tag(r, 'resourcetype') || '';
if (!/<(?:\w+:)?calendar\s*\/?>/i.test(rt)) continue;
const comps = tag(r, 'supported-calendar-component-set');
const events = !comps || /name="VEVENT"/i.test(comps), todos = !!comps && /name="VTODO"/i.test(comps);
if (!events && !todos) continue;
const href = abs(hrefOf(r), c.url);
const color = (tag(r, 'calendar-color') || '').trim().slice(0, 7);
calendars.push({ 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 });
}
if (!calendars.length) throw new Error('No calendars found on this account');
save({ user: o.user, pass: o.pass, calendars });
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 = '';
/** 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 = { connect, status, toggle, enabled, todoLists, listTodoRaw, listRaw, getResource, putEvent, deleteEvent, wipe, isCal: id => /^icloud:[0-9a-f]{10}$/.test(id || '') };