diff --git a/src/lib/icsUrl.js b/src/lib/icsUrl.js new file mode 100644 index 0000000..e172fa5 --- /dev/null +++ b/src/lib/icsUrl.js @@ -0,0 +1,12 @@ +// Apple/iCloud (and some other providers) hand out webcal:// links — that +// scheme is just a convention meaning "this is a calendar subscription, open +// it with your calendar app" and isn't something fetch() can request, so +// without this it fails with a confusing network error. webcal(s):// always +// resolves to the same feed over plain http(s), so swap the scheme and fetch +// that instead. Applied once, at add-time, so the stored URL is already the +// fetchable form and every later sync just works. +function normalizeIcsUrl(url) { + return url.replace(/^webcals?:\/\//i, 'https://'); +} + +module.exports = { normalizeIcsUrl }; diff --git a/src/routes/calendar.js b/src/routes/calendar.js index 1750795..0754023 100644 --- a/src/routes/calendar.js +++ b/src/routes/calendar.js @@ -3,6 +3,7 @@ const calendarRepo = require('../repositories/calendarRepo'); const usersRepo = require('../repositories/usersRepo'); const workflowState = require('../lib/calendarWorkflowState'); const calendarSync = require('../services/calendarSync'); +const { normalizeIcsUrl } = require('../lib/icsUrl'); const { requireAdmin } = require('../auth/middleware'); const asyncHandler = require('../lib/asyncHandler'); @@ -93,7 +94,7 @@ router.post( if (!icsUrl || !icsUrl.trim()) { return res.status(400).json({ error: 'ics_url_required' }); } - const trimmedUrl = icsUrl.trim(); + const trimmedUrl = normalizeIcsUrl(icsUrl.trim()); try { await calendarSync.testFeed(trimmedUrl); } catch (err) { @@ -148,7 +149,7 @@ router.post( if (!user) { return res.status(404).json({ error: 'user_not_found' }); } - const trimmedUrl = icsUrl.trim(); + const trimmedUrl = normalizeIcsUrl(icsUrl.trim()); try { await calendarSync.testFeed(trimmedUrl); } catch (err) { diff --git a/src/services/calendarSync.js b/src/services/calendarSync.js index e6cc92c..d232ce2 100644 --- a/src/services/calendarSync.js +++ b/src/services/calendarSync.js @@ -2,6 +2,7 @@ const ical = require('node-ical'); const calendarRepo = require('../repositories/calendarRepo'); const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability'); const { parisWallClockToUTC } = require('../lib/calendarDates'); +const { normalizeIcsUrl } = require('../lib/icsUrl'); const RANGE_DAYS = 105; // slightly more than the 14 weeks generateSlots() covers const FETCH_TIMEOUT_MS = 15000; @@ -13,7 +14,10 @@ async function fetchIcsCalendar(icsUrl) { const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); let text; try { - const response = await fetch(icsUrl, { signal: controller.signal }); + // Defensive: normalize here too, not just at add-time in routes/calendar.js, + // so a webcal:// URL stored before that fix (or inserted directly) doesn't + // keep silently failing every sync. + const response = await fetch(normalizeIcsUrl(icsUrl), { signal: controller.signal }); if (!response.ok) throw new Error(`ICS fetch failed: ${response.status}`); text = await response.text(); } finally { diff --git a/test/icsUrl.test.js b/test/icsUrl.test.js new file mode 100644 index 0000000..5c7283c --- /dev/null +++ b/test/icsUrl.test.js @@ -0,0 +1,20 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeIcsUrl } = require('../src/lib/icsUrl'); + +test('normalizeIcsUrl: rewrites webcal:// to https://', () => { + assert.equal(normalizeIcsUrl('webcal://p01-caldav.icloud.com/foo.ics'), 'https://p01-caldav.icloud.com/foo.ics'); +}); + +test('normalizeIcsUrl: rewrites webcals:// to https://', () => { + assert.equal(normalizeIcsUrl('webcals://p01-caldav.icloud.com/foo.ics'), 'https://p01-caldav.icloud.com/foo.ics'); +}); + +test('normalizeIcsUrl: is case-insensitive on the scheme', () => { + assert.equal(normalizeIcsUrl('WebCal://example.com/foo.ics'), 'https://example.com/foo.ics'); +}); + +test('normalizeIcsUrl: leaves http/https URLs untouched', () => { + assert.equal(normalizeIcsUrl('https://example.com/foo.ics'), 'https://example.com/foo.ics'); + assert.equal(normalizeIcsUrl('http://example.com/foo.ics'), 'http://example.com/foo.ics'); +});