14 KiB
ICS-URL Subscription — Implementation Plan (v2)
Date: 2026-04-20 Status: Approved Supersedes: ICS_URL_Subscription.md (v1)
Overview
Allow all family members to subscribe to external calendars via ICS URL (e.g. public Google, Outlook, or any webcal-compatible feed). Events are fetched periodically and stored locally. Users choose whether a subscription is private or shared with the whole family.
Requirements
- Any user (not just admins) can add subscriptions
- Per-subscription visibility: private (only creator) or shared (all family members)
- Custom color per subscription, chosen by the user
- Sync interval: shared with existing
SYNC_INTERVAL_MINUTESsetting - Manual "Sync now" button per subscription
- Events from subscriptions are editable; user-modified events are not overwritten on re-sync but can be reset to upstream
- Events are deleted when their subscription is deleted
- Recurring events (RRULE) are expanded within a rolling window
1. Database
1.1 New table: ics_subscriptions
CREATE TABLE ics_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1',
shared INTEGER NOT NULL DEFAULT 0, -- 0 = private, 1 = shared with all
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
etag TEXT, -- HTTP ETag for conditional fetch
last_modified TEXT, -- HTTP Last-Modified for conditional fetch
last_sync TEXT, -- ISO 8601, always UTC
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
Change from v1: ON DELETE SET NULL instead of ON DELETE CASCADE on created_by. When a user is deleted, shared subscriptions survive and can be managed by any admin. Orphaned private subscriptions (where created_by IS NULL AND shared = 0) are cleaned up by a post-deletion sweep or made visible to admins.
1.2 Migrations to calendar_events
Two new columns via append-only migration entries:
subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADEuser_modified INTEGER NOT NULL DEFAULT 0— set to1on user edit; prevents sync overwrite
Migration ordering: The ics_subscriptions CREATE TABLE entry must precede the ALTER TABLE calendar_events ADD COLUMN subscription_id entry in the migrations array.
1.3 The external_source CHECK constraint
Do not recreate the calendar_events table. Table recreation is the highest-risk migration possible — data loss, broken foreign keys, index rebuilds. Instead:
-
Option A (recommended): Drop the CHECK constraint entirely. Validate
external_source ∈ {'local', 'google', 'apple', 'ics'}at the application layer (in the route handler and inics-subscription.js). SQLite allows dropping a CHECK via table recreation, but the point is to avoid the recreation. If the existing CHECK was added inline in the original CREATE TABLE, it is already baked in. In that case, the CHECK will reject'ics'inserts. Verify the actual schema first:SELECT sql FROM sqlite_master WHERE name = 'calendar_events';If no CHECK exists → no migration needed, just validate in code. If CHECK exists → the table recreation is unavoidable, but must run inside
BEGIN IMMEDIATE/COMMITwith full column + index + FK reconstruction. Document every step. -
Option B (if CHECK must stay): Recreate in a transaction. Copy data into temp table, drop original, create with new CHECK, copy back, recreate indexes and FKs, commit. Test with a populated database before merge.
1.4 Unique constraint for upsert
Add a unique index scoped to the subscription:
CREATE UNIQUE INDEX idx_calendar_events_sub_extid
ON calendar_events (subscription_id, external_calendar_id)
WHERE subscription_id IS NOT NULL;
Rationale: ICS UIDs are only unique within a single feed, not globally. Without this scope, Feed B can overwrite Feed A's events if they share a UID. The upsert must use ON CONFLICT(subscription_id, external_calendar_id).
1.5 Visibility filter
WHERE external_source != 'ics'
OR subscription_id IN (
SELECT id FROM ics_subscriptions
WHERE shared = 1
OR created_by = :userId
)
Unchanged from v1. Events with external_source = 'ics' and subscription_id IS NULL (should not exist, but defensively) are filtered out.
2. Backend
2.1 New file: server/services/ics-parser.js
Extract from apple-calendar.js into a shared module:
| Export | Source |
|---|---|
parseICS(text) |
existing |
unfoldLines(text) |
existing |
formatICSDate(value) |
existing |
tzLocalToUTC(dateStr, tzid) |
existing |
applyDuration(start, duration) |
existing |
expandRRULE(vevent, windowStart, windowEnd) |
new |
Both apple-calendar.js and ics-subscription.js import from here. The refactoring of existing functions must be a separate commit with no logic changes, tested independently before the ICS subscription code is added.
RRULE expansion: expandRRULE generates occurrences within a rolling window (default: 6 months past → 12 months future). Supports FREQ (DAILY, WEEKLY, MONTHLY, YEARLY), COUNT, UNTIL, INTERVAL, BYDAY. EXDATE entries exclude specific occurrences. Each expanded occurrence gets a synthetic external_calendar_id of {UID}__{ISO-date} for stable upsert identity. Unsupported RRULE features (BYSETPOS, BYMONTHDAY with negative values, etc.) log a warning and fall back to non-expansion.
2.2 New file: server/services/ics-subscription.js
| Export | Description |
|---|---|
fetchAndParse(url, etag?, lastModified?) |
Validate + fetch + parse (see §2.3) |
sync(subscriptionId?) |
Sync one or all subscriptions (see §2.4) |
getAll(userId) |
Return all subscriptions visible to userId (own + shared) |
create(userId, { name, url, color, shared }) |
Validate, insert, trigger initial sync. Return subscription + sync result (success or error message) |
update(userId, id, fields) |
Update name/color/shared; only creator or admin |
remove(userId, id) |
Delete subscription (events cascade); only creator or admin |
2.3 fetchAndParse — security hardening
- Scheme whitelist: Only
https://andwebcal://(normalized tohttps://). Rejecthttp://,file://,ftp://,data://. - DNS rebinding / SSRF protection: After URL parsing, resolve the hostname. Reject if the resolved IP falls in private ranges:
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1,fc00::/7,fe80::/10. Usedns.resolve4/dns.resolve6before passing tofetch. - Timeout: 15 seconds per request (
AbortController+setTimeout). - Response size limit: Abort if
Content-Length > 10 MBor if streamed body exceeds 10 MB. - Content-Type hint check: Warn (but don't block) if response Content-Type is not
text/calendar. Some servers serve ICS astext/plain. - Conditional fetch: Send
If-None-Match: {etag}andIf-Modified-Since: {lastModified}headers. On304 Not Modified, skip parsing entirely and return early. On200, store newetagandlast-modifiedresponse headers back toics_subscriptions.
2.4 sync — operational details
- Wrap the entire sync for one subscription in
BEGIN IMMEDIATE/COMMIT. This turns N individual upserts into a single disk write. - Per-subscription mutex: Maintain an in-memory
Set<subscriptionId>of currently-syncing subscriptions. If a sync is already running for a given subscription (e.g. manual sync while periodic sync is active), skip it and return early. The Set is process-local — sufficient for single-process Oikos. - Upsert logic:
INSERT ... ON CONFLICT(subscription_id, external_calendar_id) DO UPDATE SET ... WHERE user_modified = 0. Events whereuser_modified = 1are untouched in a single statement — no per-row branching needed. - Stale event cleanup: After upsert, delete events belonging to this subscription whose
external_calendar_idis not in the current feed's UID set AND whoseuser_modified = 0. User-modified events whose upstream counterpart disappeared are kept (the user explicitly edited them). - On fetch error: log warning, leave existing events and
last_syncunchanged, continue to next subscription.
2.5 New routes: /api/v1/calendar/subscriptions
| Method | Path | Action | Auth |
|---|---|---|---|
GET |
/ |
List visible subscriptions | any user |
POST |
/ |
Create subscription | any user |
PATCH |
/:id |
Update name/color/shared | creator or admin |
DELETE |
/:id |
Delete subscription + events | creator or admin |
POST |
/:id/sync |
Manual sync now | creator or admin |
All handlers in try/catch. Responses follow { data: ... } / { error, code }.
Input validation on POST/PATCH:
url: required, must parse as valid URL, scheme must behttpsorwebcalname: required, non-empty, max 100 charscolor: required on POST, must match/^#[0-9a-fA-F]{6}$/shared: boolean-coercible integer (0 or 1)
2.6 Setting user_modified
When PATCH /api/v1/calendar/events/:id updates an event with external_source = 'ics', the handler sets user_modified = 1 automatically.
New: PATCH /api/v1/calendar/events/:id/reset sets user_modified = 0 on an ICS event. The next sync cycle will overwrite it with upstream data. Returns { data: { reset: true } }. Only the event creator, subscription creator, or admin can call this.
2.7 Sync integration
server/index.js syncAll() calls icsSubscription.sync() alongside existing Google/Apple sync. ICS sync runs last (lowest priority — Google/Apple are authenticated and more critical).
2.8 Orphan cleanup
After a user is deleted (ON DELETE SET NULL on created_by), run a sweep:
DELETE FROM ics_subscriptions WHERE created_by IS NULL AND shared = 0;
This removes private subscriptions that no one can see or manage. Shared orphans remain visible and editable by admins.
3. Frontend
3.1 Settings page (public/pages/settings.js)
New card "ICS-Abonnements" in the existing "Kalender" tab, below Apple Calendar:
- List of visible subscriptions: color dot, name, visibility badge (
Privat/Geteilt), last sync timestamp (viaformatDate()+formatTime()), sync error indicator if last sync failed - "Abonnement hinzufügen" button reveals inline form:
- URL input (required,
type="url") - Name input (required)
- Color picker (
<input type="color">) - Toggle "Für alle sichtbar" (default: off)
- Submit / Cancel buttons
- URL input (required,
- Per-subscription actions: "Jetzt synchronisieren" (shows spinner during sync), "Bearbeiten" (inline), "Löschen" (confirmation via existing confirm pattern)
- Only creator or admin sees edit/delete/sync actions
- Initial sync error on create: show inline warning with error message, subscription is still created
Rendered inline — no new Web Component. Consistent with Apple Calendar form pattern in the same tab.
3.2 Calendar page (public/pages/calendar.js)
- Events with
external_source = 'ics'render with their subscription's color - No special UI indicator for
user_modified— keeps UX clean - Event detail view for
user_modified = 1events shows a subtle "Auf Original zurücksetzen" link that callsPATCH .../reset
3.3 i18n
All new strings in public/locales/de.json:
settings.ics.title— "ICS-Abonnements"settings.ics.add— "Abonnement hinzufügen"settings.ics.form.*— URL, Name, Color, Shared toggle labelssettings.ics.actions.*— Sync, Edit, Delete labelssettings.ics.status.*— last sync, sync error, syncing statessettings.ics.confirm_delete— deletion confirmationsettings.ics.badges.*— "Privat", "Geteilt"calendar.ics.reset— "Auf Original zurücksetzen"
de is the reference locale. Other locales fall back gracefully via t().
4. Error Handling
| Scenario | Behavior |
|---|---|
| ICS URL unreachable / timeout | Log warning, keep existing events, leave last_sync unchanged |
| 304 Not Modified | Skip parse, update last_sync timestamp only |
| Invalid ICS content | Log warning, skip malformed VEVENTs, continue with valid ones |
URL returns non-ICS content (no BEGIN:VCALENDAR) |
Log error, abort sync for this subscription |
| Response > 10 MB | Abort fetch, log error |
| SSRF attempt (private IP) | Reject with 400: "URL resolves to a private address" |
| Unsupported URL scheme | Reject with 400: "Only https and webcal URLs are supported" |
| RRULE with unsupported features | Log warning per event, fall back to single occurrence |
| Unauthorized edit/delete | 403 response |
| Duplicate URL across subscriptions | Allowed (user may want same feed with different color/name) |
| Initial sync fails on create | Subscription created, error message returned in response body |
| Concurrent sync on same subscription | Second sync skipped (in-memory mutex) |
5. Commit Strategy
| # | Scope | Description |
|---|---|---|
| 1 | refactor(calendar) |
Extract ICS parser from apple-calendar.js into server/services/ics-parser.js. No logic changes. Existing Apple Calendar tests must still pass. |
| 2 | feat(calendar) |
Add ics_subscriptions table, calendar_events columns, unique index. Add RRULE expansion to parser. Migrations in correct order. |
| 3 | feat(calendar) |
Add ics-subscription.js service, routes, sync integration, security hardening. Backend tests. |
| 4 | feat(calendar) |
Frontend: settings card, calendar color rendering, reset flow, i18n keys. |
6. Out of Scope
- CalDAV authentication (Basic/OAuth) — ICS-URL only (public or pre-authenticated URLs)
- Per-event sync conflict resolution UI (beyond the reset button)
- Subscription import/export
- VTIMEZONE definitions beyond offset-based conversion (use system timezone as fallback)
- RRULE features beyond FREQ/COUNT/UNTIL/INTERVAL/BYDAY/EXDATE