update: work with webcal

This commit is contained in:
Nathan FONTEYNE 2026-07-23 11:52:09 +02:00
parent 5dce6eb89b
commit fd81b74594
4 changed files with 40 additions and 3 deletions

12
src/lib/icsUrl.js Normal file
View file

@ -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 };

View file

@ -3,6 +3,7 @@ const calendarRepo = require('../repositories/calendarRepo');
const usersRepo = require('../repositories/usersRepo'); const usersRepo = require('../repositories/usersRepo');
const workflowState = require('../lib/calendarWorkflowState'); const workflowState = require('../lib/calendarWorkflowState');
const calendarSync = require('../services/calendarSync'); const calendarSync = require('../services/calendarSync');
const { normalizeIcsUrl } = require('../lib/icsUrl');
const { requireAdmin } = require('../auth/middleware'); const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
@ -93,7 +94,7 @@ router.post(
if (!icsUrl || !icsUrl.trim()) { if (!icsUrl || !icsUrl.trim()) {
return res.status(400).json({ error: 'ics_url_required' }); return res.status(400).json({ error: 'ics_url_required' });
} }
const trimmedUrl = icsUrl.trim(); const trimmedUrl = normalizeIcsUrl(icsUrl.trim());
try { try {
await calendarSync.testFeed(trimmedUrl); await calendarSync.testFeed(trimmedUrl);
} catch (err) { } catch (err) {
@ -148,7 +149,7 @@ router.post(
if (!user) { if (!user) {
return res.status(404).json({ error: 'user_not_found' }); return res.status(404).json({ error: 'user_not_found' });
} }
const trimmedUrl = icsUrl.trim(); const trimmedUrl = normalizeIcsUrl(icsUrl.trim());
try { try {
await calendarSync.testFeed(trimmedUrl); await calendarSync.testFeed(trimmedUrl);
} catch (err) { } catch (err) {

View file

@ -2,6 +2,7 @@ const ical = require('node-ical');
const calendarRepo = require('../repositories/calendarRepo'); const calendarRepo = require('../repositories/calendarRepo');
const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability'); const { generateSlots, isBusyDuring, widenWindow } = require('../lib/calendarAvailability');
const { parisWallClockToUTC } = require('../lib/calendarDates'); const { parisWallClockToUTC } = require('../lib/calendarDates');
const { normalizeIcsUrl } = require('../lib/icsUrl');
const RANGE_DAYS = 105; // slightly more than the 14 weeks generateSlots() covers const RANGE_DAYS = 105; // slightly more than the 14 weeks generateSlots() covers
const FETCH_TIMEOUT_MS = 15000; const FETCH_TIMEOUT_MS = 15000;
@ -13,7 +14,10 @@ async function fetchIcsCalendar(icsUrl) {
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let text; let text;
try { 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}`); if (!response.ok) throw new Error(`ICS fetch failed: ${response.status}`);
text = await response.text(); text = await response.text();
} finally { } finally {

20
test/icsUrl.test.js Normal file
View file

@ -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');
});