From 74e21c1edc3101e9e2a4ffb50cc74e2e98218ccf Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 7 Oct 2026 19:57:22 -0700 Subject: [PATCH] [pending-judge] feat(DC-161): calendar widget + Radicale auto-wiring - Top-bar calendar widget (status/js/calendar-widget.js): next-appointment display, settings modal with CalDAV/ICS source management and a Test-connection probe. Follows the weather-widget pattern. - Backend routes/calendar.js: sources CRUD (secrets in credential manager, never in responses), aggregated events across sources with per-source error isolation, Radicale provision endpoint, CalDAV discover probe. - src/calendar/ics-parser.js: dependency-free RFC 5545 parser with bounded RRULE expansion (DAILY/WEEKLY-BYDAY/MONTHLY/YEARLY, COUNT/UNTIL/INTERVAL, EXDATE consumes COUNT, RECURRENCE-ID overrides), 500-event cap. - src/calendar/caldav-client.js: CalDAV REPORT time-range queries over node:https (no new deps), HTTPS-enforced off localhost, 5MiB response cap. - src/calendar/radicale-provisioner.js: zero-config auto-wiring - generates a 24-char password, writes Radicale htpasswd config (pure-Node APR1 hash verified byte-for-byte against openssl passwd -apr1 vectors), stores credentials, registers the source. Deploy hook in routes/apps/deploy.js wires any Radicale deployed via the App Selector automatically. - Radicale app template (tomsquest/docker-radicale) in the catalog. - Tests: 32 new (parser unit, provisioner unit incl. openssl vectors, route tests). Full suite 3552 passed; only pre-existing pdf-parse billing/invoice failures remain (proven red at base 448f535). - OpenAPI: 5 calendar paths documented. CHANGELOG entry added. --- CHANGELOG.md | 17 + .../__tests__/routes/calendar.routes.test.js | 221 ++++++++++++ .../__tests__/unit/ics-parser.unit.test.js | 122 +++++++ .../unit/radicale-provisioner.unit.test.js | 120 +++++++ dashcaddy-api/openapi.yaml | 97 ++++++ dashcaddy-api/routes/apps/deploy.js | 23 ++ dashcaddy-api/routes/calendar.js | 222 ++++++++++++ dashcaddy-api/src/app.js | 18 + dashcaddy-api/src/calendar/caldav-client.js | 170 +++++++++ dashcaddy-api/src/calendar/ics-parser.js | 329 ++++++++++++++++++ .../src/calendar/radicale-provisioner.js | 166 +++++++++ dashcaddy-api/src/context/index.js | 2 + dashcaddy-api/src/docker/app-templates.js | 32 ++ status/build.js | 3 + status/css/dashboard.css | 47 +++ status/index.html | 9 + status/js/calendar-widget.js | 246 +++++++++++++ status/js/globals.js | 1 + 18 files changed, 1845 insertions(+) create mode 100644 dashcaddy-api/__tests__/routes/calendar.routes.test.js create mode 100644 dashcaddy-api/__tests__/unit/ics-parser.unit.test.js create mode 100644 dashcaddy-api/__tests__/unit/radicale-provisioner.unit.test.js create mode 100644 dashcaddy-api/routes/calendar.js create mode 100644 dashcaddy-api/src/calendar/caldav-client.js create mode 100644 dashcaddy-api/src/calendar/ics-parser.js create mode 100644 dashcaddy-api/src/calendar/radicale-provisioner.js create mode 100644 status/js/calendar-widget.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5c877..0cd73f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to DashCaddy are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **DC-161: Calendar widget + Radicale auto-wiring.** New top-bar calendar widget + (`status/js/calendar-widget.js`) showing upcoming appointments; settings modal for + adding CalDAV / ICS-feed sources with a Test-connection probe. Backend: + `routes/calendar.js` (`GET/POST/PUT/DELETE /api/v1/calendar/sources`, + `GET /api/v1/calendar/events`, `POST /api/v1/calendar/radicale/provision`, + `POST /api/v1/calendar/discover`), dependency-free RFC 5545 ICS parser with + bounded RRULE expansion (`src/calendar/ics-parser.js`), CalDAV REPORT client + over node:https (`src/calendar/caldav-client.js`). When Radicale is deployed + through the App Selector, the deploy hook auto-provisions htpasswd credentials + (pure-Node APR1 hash, verified byte-for-byte against `openssl passwd -apr1`) + and registers the calendar source — the widget connects with zero user input. + Manual bring-your-own sources (Radicale/Baikal/Nextcloud hosted elsewhere, + iCloud/Fastmail CalDAV, plain ICS feeds) wire in through the settings modal. + ## [1.17.0] - 2026-10-07 ### Added diff --git a/dashcaddy-api/__tests__/routes/calendar.routes.test.js b/dashcaddy-api/__tests__/routes/calendar.routes.test.js new file mode 100644 index 0000000..7d3c39b --- /dev/null +++ b/dashcaddy-api/__tests__/routes/calendar.routes.test.js @@ -0,0 +1,221 @@ +/** + * DC-161: Calendar routes tests. + * Covers: sources CRUD (secret never in response), validation rejections, + * events aggregation with a failing source (partial success), Radicale + * provision happy path + idempotence, discover probe error swallow. + */ + +const express = require('express'); +const http = require('http'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function buildRouter(overrides = {}) { + jest.resetModules(); + // Keep provision tests off the real filesystem + process.env.DC_RADICALE_CONFIG_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'dc161-provision-')); + const mod = require('../../routes/calendar'); + const stored = {}; + const items = []; + const deps = { + calendarStateManager: { + read: jest.fn(async () => items), + addItem: jest.fn(async (item) => { items.push(item); return item; }), + removeItem: jest.fn(async (id) => { const i = items.findIndex(x => x.id === id); if (i >= 0) items.splice(i, 1); }), + updateItem: jest.fn(async (id, updates) => { const it = items.find(x => x.id === id); if (it) Object.assign(it, updates); return it; }), + findItem: jest.fn(async (id) => items.find(x => x.id === id) || null), + _items: items, + }, + credentialManager: { + store: jest.fn(async (key, value) => { stored[key] = value; }), + retrieve: jest.fn(async (key) => stored[key] ?? null), + delete: jest.fn(async (key) => { delete stored[key]; }), + _stored: stored, + }, + docker: null, + asyncHandler: (fn) => async (req, res, next) => { + try { await fn(req, res, next); } catch (e) { next(e); } + }, + ok: (res, body) => res.json({ success: true, ...body }), + errorResponse: (res, code, msg) => res.status(code).json({ success: false, error: msg }), + log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + fetchT: jest.fn(), + buildServiceUrl: (sub) => `https://${sub}.sami`, + ...overrides, + }; + const router = express(); + router.use(express.json()); + router.use('/api/v1/calendar', mod(deps)); + // Mirror prod error handling: AppError → JSON with its statusCode + // (the real app's error middleware returns { success:false, error }). + // eslint-disable-next-line no-unused-vars + router.use((err, req, res, next) => { + const status = err && err.statusCode ? err.statusCode : 500; + res.status(status).json({ success: false, error: err.message }); + }); + deps._items = items; + deps._stored = stored; + return { app: router, deps }; +} + +function listen(app) { + return new Promise((resolve) => { + const server = http.createServer(app); + server.listen(0, () => resolve({ server, port: server.address().port })); + }); +} + +// Helper that spins a fresh server per test (fresh state each time) +async function withServer(fn, overrides) { + const { app, deps } = buildRouter(overrides); + const { server, port } = await listen(app); + try { + await fn(port, deps); + } finally { + await new Promise((r) => server.close(r)); + } +} + +const j = (r) => r.json(); + +describe('DC-161 calendar routes', () => { + describe('sources CRUD', () => { + test('POST /sources requires name/type/url', async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/name, type and url/); + expect(body.success).toBe(false); + }); + }); + + test('POST /sources rejects non-http(s) and remote-plaintext URLs', async () => { + await withServer(async (port) => { + const mk = (url) => fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'x', type: 'ics', url }), + }); + expect((await mk('ftp://example.com/cal.ics')).status).toBe(400); + expect((await mk('http://example.com/cal.ics')).status).toBe(400); + expect((await mk('http://localhost/cal.ics')).status).toBe(200); + }); + }); + + test('CalDAV source without user/token is rejected', async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'x', type: 'caldav', url: 'https://cal.example.com/u/' }), + }); + expect(res.status).toBe(400); + }); + }); + + test('stored pass never appears in the sources response', async () => { + await withServer(async (port, deps) => { + await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Work', type: 'caldav', url: 'https://cal.example.com/u/', user: 'sam', pass: 'hunter2' }), + }); + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`); + const text = await res.text(); + expect(text).not.toContain('hunter2'); + expect(deps._stored['calendar:source:' + deps._items[0].id]).toContain('hunter2'); + }); + }); + + test('DELETE removes the source and its credential', async () => { + await withServer(async (port, deps) => { + const add = await (await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'W', type: 'ics', url: 'https://x.example.com/c.ics' }), + })).json(); + const id = add.source.id; + const del = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/sources/${id}`, { method: 'DELETE' }); + expect((await del.json()).removed).toBe(true); + expect(deps._items).toHaveLength(0); + expect(deps.credentialManager.delete).toHaveBeenCalledWith(`calendar:source:${id}`); + }); + }); + }); + + describe('events aggregation', () => { + test('a failing source degrades to perSource error, others still return', async () => { + await withServer(async (port, deps) => { + deps._items.push( + { id: 'good', type: 'ics', name: 'Good', url: 'https://good.example.com/c.ics', enabled: true }, + { id: 'bad', type: 'ics', name: 'Bad', url: 'https://bad.example.com/c.ics', enabled: true }, + ); + const goodIcs = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:g1\r\nDTSTART:20261007T100000Z\r\nSUMMARY:Good event\r\nEND:VEVENT\r\nEND:VCALENDAR'; + deps.fetchT.mockImplementation(async (url) => { + if (url.includes('good')) return { ok: true, status: 200, text: async () => goodIcs }; + throw new Error('DNS failure'); + }); + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events`); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.events.map(e => e.title)).toEqual(['Good event']); + expect(body.perSource).toEqual(expect.arrayContaining([ + expect.objectContaining({ sourceId: 'good', ok: true }), + expect.objectContaining({ sourceId: 'bad', ok: false }), + ])); + expect(body.errors.join(' ')).toMatch(/Bad/); + }); + }); + + test('disabled sources are skipped', async () => { + await withServer(async (port, deps) => { + deps._items.push({ id: 'off', type: 'ics', name: 'Off', url: 'https://off.example.com/c.ics', enabled: false }); + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events`); + const body = await res.json(); + expect(body.events).toHaveLength(0); + expect(body.perSource).toHaveLength(0); + expect(deps.fetchT).not.toHaveBeenCalled(); + }); + }); + + test('invalid window is rejected with 400', async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/events?start=nonsense`); + expect(res.status).toBe(400); + }); + }); + }); + + describe('radicale provision', () => { + test('registers an auto-wired source and stores credentials', async () => { + await withServer(async (port, deps) => { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/radicale/provision`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subdomain: 'calendar', serviceUrl: 'https://calendar.sami' }), + }); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.source.autoProvisioned).toBe(true); + expect(body.source.url).toBe('https://calendar.sami/dashcaddy/'); + const saved = JSON.parse(deps._stored['calendar:radicale:calendar']); + expect(saved.user).toBe('dashcaddy'); + expect(saved.pass).toMatch(/^[A-Za-z0-9]{24}$/); + // htpasswd was written for that exact password (inside the test root) + const fs = require('fs'); + const root = process.env.DC_RADICALE_CONFIG_ROOT; + const users = fs.readFileSync(path.join(root, 'config', 'users'), 'utf8'); + expect(users).toContain('dashcaddy:$apr1$'); + }); + }); + + test('invalid subdomain is rejected', async () => { + await withServer(async (port) => { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/calendar/radicale/provision`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subdomain: '../etc' }), + }); + expect(res.status).toBe(400); + }); + }); + }); +}); diff --git a/dashcaddy-api/__tests__/unit/ics-parser.unit.test.js b/dashcaddy-api/__tests__/unit/ics-parser.unit.test.js new file mode 100644 index 0000000..b6bb8d5 --- /dev/null +++ b/dashcaddy-api/__tests__/unit/ics-parser.unit.test.js @@ -0,0 +1,122 @@ +/** + * DC-161: ICS parser unit tests. + * Covers: basic VEVENT extraction, all-day events, RRULE expansion + * (DAILY/WEEKLY-BYDAY/MONTHLY + COUNT/UNTIL/INTERVAL), EXDATE, + * RECURRENCE-ID overrides, event cap, malformed DTSTART. + */ + +const { parseIcs, parseIcsDate, unfoldLines } = require('../../src/calendar/ics-parser'); + +const RANGE = { start: new Date('2026-10-01T00:00:00Z'), end: new Date('2026-11-01T00:00:00Z') }; + +function ics(body) { + return `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n${body}\r\nEND:VCALENDAR`; +} + +describe('DC-161 ics-parser', () => { + test('parses a simple timed event', () => { + const { events, errors } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:1\r\nDTSTART:20261007T140000Z\r\nDTEND:20261007T150000Z\r\nSUMMARY:Dentist\\, cleaning\r\nLOCATION:Main St\r\nEND:VEVENT' + ), RANGE); + expect(errors).toEqual([]); + expect(events).toHaveLength(1); + expect(events[0].title).toBe('Dentist, cleaning'); + expect(events[0].start.toISOString()).toBe('2026-10-07T14:00:00.000Z'); + expect(events[0].allDay).toBe(false); + expect(events[0].location).toBe('Main St'); + }); + + test('parses an all-day event (DATE value)', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:2\r\nDTSTART;VALUE=DATE:20261010\r\nSUMMARY:Conference\r\nEND:VEVENT' + ), RANGE); + expect(events[0].allDay).toBe(true); + expect(events[0].start.getFullYear()).toBe(2026); + expect(events[0].start.getMonth()).toBe(9); + expect(events[0].start.getDate()).toBe(10); + }); + + test('daily recurrence with COUNT expands within window', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:3\r\nDTSTART:20261001T090000Z\r\nDTEND:20261001T093000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Standup\r\nEND:VEVENT' + ), RANGE); + expect(events).toHaveLength(5); + expect(events[0].start.getUTCDate()).toBe(1); + expect(events[4].start.getUTCDate()).toBe(5); + expect(events.every(e => e.recurring)).toBe(true); + }); + + test('weekly BYDAY expansion lands on correct weekdays', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:4\r\nDTSTART:20261005T100000Z\r\nDTEND:20261005T110000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\nSUMMARY:Gym\r\nEND:VEVENT' + ), RANGE); + // Oct 5 2026 is a Monday. Expect 3/wk for the 4 weeks in October window. + expect(events.length).toBeGreaterThanOrEqual(11); + for (const ev of events) { + expect([1, 3, 5]).toContain(ev.start.getUTCDay()); + } + }); + + test('EXDATE excludes occurrences', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:5\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;COUNT=4\r\nEXDATE:20261003T090000Z\r\nSUMMARY:Except Wednesday\r\nEND:VEVENT' + ), RANGE); + expect(events).toHaveLength(3); + expect(events.some(e => e.start.getUTCDate() === 3)).toBe(false); + }); + + test('UNTIL bounds recurrence', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:6\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;UNTIL=20261004T090000Z\r\nSUMMARY:Bounded\r\nEND:VEVENT' + ), RANGE); + expect(events).toHaveLength(4); + }); + + test('events outside window are excluded; window straddles inclusion', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:7\r\nDTSTART:20260901T090000Z\r\nDTEND:20260901T100000Z\r\nSUMMARY:September only\r\nEND:VEVENT' + ), RANGE); + expect(events).toHaveLength(0); + }); + + test('RECURRENCE-ID override replaces instance title', () => { + const { events } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:8\r\nDTSTART:20261001T090000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Original\r\nEND:VEVENT\r\n' + + 'BEGIN:VEVENT\r\nUID:8\r\nRECURRENCE-ID:20261002T090000Z\r\nDTSTART:20261002T130000Z\r\nSUMMARY:Moved to 1pm\r\nEND:VEVENT' + ), RANGE); + const moved = events.find(e => e.start.getUTCDate() === 2); + expect(moved.title).toBe('Moved to 1pm'); + expect(moved.start.getUTCHours()).toBe(13); + }); + + test('malformed DTSTART is reported as an error, not thrown', () => { + const { events, errors } = parseIcs(ics( + 'BEGIN:VEVENT\r\nUID:9\r\nDTSTART:notadate\r\nSUMMARY:Broken\r\nEND:VEVENT' + ), RANGE); + expect(events).toHaveLength(0); + expect(errors.length).toBeGreaterThanOrEqual(1); + }); + + test('event count is capped at 500', () => { + let body = ''; + for (let i = 0; i < 600; i++) { + body += `BEGIN:VEVENT\r\nUID:x${i}\r\nDTSTART:20261001T${String(i % 24).padStart(2, '0')}0000Z\r\nSUMMARY:E${i}\r\nEND:VEVENT\r\n`; + } + const { events } = parseIcs(ics(body), RANGE); + expect(events).toHaveLength(500); + }); + + test('unfoldLines joins folded lines (RFC: CRLF + one WSP removed)', () => { + // Two leading spaces: fold char removed, second space is content + expect(unfoldLines('SUMMARY:hello\r\n world')).toEqual(['SUMMARY:hello world']); + // Exactly one space: fully joined + expect(unfoldLines('SUMMARY:hello\r\n world')).toEqual(['SUMMARY:helloworld']); + }); + + test('parseIcsDate handles UTC, floating, and date forms', () => { + expect(parseIcsDate('20261007T140000Z').toISOString()).toBe('2026-10-07T14:00:00.000Z'); + expect(parseIcsDate('20261007')).toEqual(new Date(2026, 9, 7)); + expect(parseIcsDate('20261007T140000')).toEqual(new Date(2026, 9, 7, 14, 0, 0)); + expect(parseIcsDate('garbage')).toBeNull(); + }); +}); diff --git a/dashcaddy-api/__tests__/unit/radicale-provisioner.unit.test.js b/dashcaddy-api/__tests__/unit/radicale-provisioner.unit.test.js new file mode 100644 index 0000000..1283719 --- /dev/null +++ b/dashcaddy-api/__tests__/unit/radicale-provisioner.unit.test.js @@ -0,0 +1,120 @@ +/** + * DC-161: Radicale provisioner unit tests. + * The APR1 vectors below are ground truth generated by `openssl passwd -apr1 + * -salt ` on this host — the Node port must reproduce them + * byte-for-byte or Radicale's htpasswd auth will reject the user. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { apr1Hash, generatePassword, writeRadicaleConfig, registerAutoWiredSource } = require('../../src/calendar/radicale-provisioner'); + +describe('DC-161 radicale-provisioner', () => { + describe('apr1Hash — openssl ground-truth vectors', () => { + test.each([ + ['MyTestPass123', 'abcd1234', '$apr1$abcd1234$DYT32mQm7IRMuLT7wjldt.'], + ['calender-wire-2026', 'zzzz9999', '$apr1$zzzz9999$WIDHV/rdr9g9GeH7PCFNk.'], + ])('hashes %s with salt %s to openssl output', (pw, salt, expected) => { + expect(apr1Hash(pw, salt)).toBe(expected); + }); + + test('generates an 8-char salt and 22-char hash when salt omitted', () => { + const h = apr1Hash('somepass'); + const parts = h.split('$'); // ['', 'apr1', salt, hash] + expect(parts[1]).toBe('apr1'); + expect(parts[2]).toHaveLength(8); + expect(parts[3]).toHaveLength(22); + }); + + test('same password + salt is deterministic; different salts differ', () => { + expect(apr1Hash('x', 'saltsalt')).toBe(apr1Hash('x', 'saltsalt')); + expect(apr1Hash('x', 'saltsalt')).not.toBe(apr1Hash('x', 'othersal')); + }); + + test('truncates long salts to 8 chars (Apache behavior)', () => { + expect(apr1Hash('x', '1234567890123')).toBe(apr1Hash('x', '12345678')); + }); + }); + + test('generatePassword is 24 chars and unique per call', () => { + const a = generatePassword(); + const b = generatePassword(); + expect(a).toHaveLength(24); + expect(b).toHaveLength(24); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9]{24}$/); + }); + + describe('writeRadicaleConfig', () => { + let tmpDir; + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'radicale-test-')); }); + afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + + test('writes config + users file with htpasswd line', async () => { + await writeRadicaleConfig(tmpDir, { user: 'dashcaddy', pass: 'secret123' }); + const config = fs.readFileSync(path.join(tmpDir, 'config'), 'utf8'); + const users = fs.readFileSync(path.join(tmpDir, 'users'), 'utf8'); + expect(config).toContain('[auth]'); + expect(config).toContain('type = htpasswd'); + expect(config).toContain('htpasswd_filename = /config/users'); + expect(users).toMatch(/^dashcaddy:\$apr1\$[./0-9A-Za-z]{8}\$[./0-9A-Za-z]{22}\n$/); + // The plaintext password must never appear in any written file + expect(users).not.toContain('secret123'); + expect(config).not.toContain('secret123'); + }); + + test('users file is written 0600', async () => { + await writeRadicaleConfig(tmpDir, { user: 'u', pass: 'p' }); + const mode = fs.statSync(path.join(tmpDir, 'users')).mode & 0o777; + expect(mode).toBe(0o600); + }); + }); + + describe('registerAutoWiredSource', () => { + function makeMocks() { + const stored = {}; + const items = []; + return { + stored, items, + calendarStateManager: { + addItem: jest.fn(async (item) => { items.push(item); return item; }), + read: jest.fn(async () => items), + }, + credentialManager: { + store: jest.fn(async (key, value) => { stored[key] = value; }), + retrieve: jest.fn(async (key) => stored[key] ?? null), + }, + }; + } + + test('stores credentials under calendar key and registers an enabled source', async () => { + const mocks = makeMocks(); + const source = await registerAutoWiredSource({ + calendarStateManager: mocks.calendarStateManager, + credentialManager: mocks.credentialManager, + subdomain: 'calendar', + serviceUrl: 'https://calendar.sami', + creds: { user: 'dashcaddy', pass: 'pw123' }, + }); + expect(source.url).toBe('https://calendar.sami/dashcaddy/'); + expect(source.autoProvisioned).toBe(true); + expect(source.enabled).toBe(true); + expect(mocks.calendarStateManager.addItem).toHaveBeenCalledTimes(1); + const saved = JSON.parse(mocks.stored['calendar:radicale:calendar']); + expect(saved).toEqual({ user: 'dashcaddy', pass: 'pw123' }); + }); + + test('trailing slash in serviceUrl does not double-slash the principal URL', async () => { + const mocks = makeMocks(); + const source = await registerAutoWiredSource({ + calendarStateManager: mocks.calendarStateManager, + credentialManager: mocks.credentialManager, + subdomain: 'cal', + serviceUrl: 'https://cal.sami/', + creds: { user: 'u', pass: 'p' }, + }); + expect(source.url).toBe('https://cal.sami/u/'); + }); + }); +}); diff --git a/dashcaddy-api/openapi.yaml b/dashcaddy-api/openapi.yaml index a2d18cd..4d13273 100644 --- a/dashcaddy-api/openapi.yaml +++ b/dashcaddy-api/openapi.yaml @@ -7676,6 +7676,103 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' + /api/v1/calendar/sources: + get: + tags: [Calendar] + summary: List calendar sources + description: Returns all configured calendar sources (CalDAV/ICS). Secrets are never included. + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '401': + description: Unauthorized + post: + tags: [Calendar] + summary: Add a calendar source + description: Registers a CalDAV or read-only ICS calendar. CalDAV sources require user+pass or a token. + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Validation error (bad type/URL, plaintext HTTP to a non-local host) + + /api/v1/calendar/sources/{id}: + put: + tags: [Calendar] + summary: Update a calendar source + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Successful operation + '404': + description: Source not found + delete: + tags: [Calendar] + summary: Remove a calendar source + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Successful operation + '404': + description: Source not found + + /api/v1/calendar/events: + get: + tags: [Calendar] + summary: Aggregated calendar events + description: Fetches and merges events from all enabled sources over a time window. RRULE expansion is applied server-side. + parameters: + - name: start + in: query + schema: { type: string, format: date-time } + - name: end + in: query + schema: { type: string, format: date-time } + - name: sourceId + in: query + schema: { type: string } + responses: + '200': + description: Events plus per-source status (a failing source does not fail the request) + '400': + description: Invalid window or range exceeds 400 days + + /api/v1/calendar/radicale/provision: + post: + tags: [Calendar] + summary: Auto-wire a deployed Radicale instance + description: Generates credentials, writes the Radicale htpasswd config, and registers the calendar source for zero-config widget wiring. + responses: + '200': + description: Successful operation + '400': + description: Invalid subdomain + + /api/v1/calendar/discover: + post: + tags: [Calendar] + summary: Probe a CalDAV URL + description: OPTIONS + PROPFIND probe used by the settings modal "Test connection" button. + responses: + '200': + description: Probe result (ok flag inside — transport errors are reported, not thrown) + components: schemas: SuccessResponse: diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index 3e7909b..6359975 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -408,6 +408,29 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag }); log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain }); + // DC-161: calendar apps auto-wire the dashboard calendar widget. + // Zero user input — credentials + source registered server-side. + if (appId === 'radicale' && ctx.calendarStateManager) { + try { + const { generatePassword, writeRadicaleConfig, registerAutoWiredSource } = require('../../src/calendar/radicale-provisioner'); + const calUser = 'dashcaddy'; + const calPass = generatePassword(); + await writeRadicaleConfig('/opt/radicale/config', { user: calUser, pass: calPass }); + await registerAutoWiredSource({ + calendarStateManager: ctx.calendarStateManager, + credentialManager, + subdomain: config.subdomain, + serviceUrl, + creds: { user: calUser, pass: calPass }, + templateId: 'radicale', + }); + log.info('deploy', 'Calendar widget auto-wired to Radicale', { subdomain: config.subdomain }); + } catch (calError) { + // Non-fatal: the app is deployed; wiring can be redone via POST /api/v1/calendar/radicale/provision + log.warn('deploy', 'Calendar auto-wiring failed (non-fatal)', { error: calError.message }); + } + } + const response = { success: true, containerId, usedExisting, url: serviceUrl, diff --git a/dashcaddy-api/routes/calendar.js b/dashcaddy-api/routes/calendar.js new file mode 100644 index 0000000..8ea96d5 --- /dev/null +++ b/dashcaddy-api/routes/calendar.js @@ -0,0 +1,222 @@ +/** + * DC-161: Calendar routes — wire any CalDAV/ICS calendar into the dashboard. + * + * GET /api/v1/calendar/sources list configured sources (secrets never returned) + * POST /api/v1/calendar/sources add a source (caldav | ics) + * PUT /api/v1/calendar/sources/:id update (url / user / pass / enabled / name) + * DELETE /api/v1/calendar/sources/:id remove + * GET /api/v1/calendar/events?start=&end=&sourceId= aggregated events across enabled sources + * POST /api/v1/calendar/radicale/provision auto-wire a DashCaddy-deployed Radicale + * POST /api/v1/calendar/discover probe URL for CalDAV capability (used by Settings modal) + * + * Auto-wiring contract (Sami, 2026-10): when DashCaddy itself deployed the + * calendar server, the widget must connect with zero user input — the + * provision endpoint pairs the credential manager with a calendar source + * record; the dashboard reads sources on load and renders silently. + * + * Route naming follows the public-routes list in src/utilities/middleware.js: + * everything here requires auth (calendar data is personal). + */ + +'use strict'; + +const express = require('express'); +const { ValidationError } = require('../src/utilities/errors'); +const { probeCalendar, fetchEventsInRange } = require('../src/calendar/caldav-client'); +const { parseIcs } = require('../src/calendar/ics-parser'); +const { + generatePassword, writeRadicaleConfig, registerAutoWiredSource, +} = require('../src/calendar/radicale-provisioner'); + +const CONFIG_DIR_ROOT = process.env.DC_RADICALE_CONFIG_ROOT || '/opt/radicale'; + +module.exports = function createCalendarRoutes({ + calendarStateManager, + credentialManager, + docker, + asyncHandler, + ok, + errorResponse, + log, + fetchT, + buildServiceUrl, +}) { + const router = express.Router(); + + function findSource(id) { + return calendarStateManager.findItem(id); + } + + // ---- Sources CRUD ------------------------------------------------------- + + router.get('/sources', asyncHandler(async (req, res) => { + const sources = await calendarStateManager.read(); + // Never return stored secrets — only metadata. + ok(res, { sources }); + }, 'calendar-list-sources')); + + router.post('/sources', asyncHandler(async (req, res) => { + const { name, type, url, user, pass, token, enabled } = req.body || {}; + if (!name || !type || !url) throw new ValidationError('[DC-405] name, type and url are required'); + if (!['caldav', 'ics'].includes(type)) throw new ValidationError('[DC-405] type must be "caldav" or "ics"'); + let parsed; + try { parsed = new URL(url); } catch (_) { throw new ValidationError('[DC-405] url must be an absolute URL'); } + if (!['https:', 'http:'].includes(parsed.protocol)) throw new ValidationError('[DC-405] url must be http(s)'); + if (parsed.protocol === 'http:' && !['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname)) { + throw new ValidationError('[DC-401] plaintext HTTP is only allowed for localhost sources'); + } + if (type === 'caldav' && !user && !token) throw new ValidationError('[DC-405] CalDAV sources need user+pass or a token'); + + const source = { + id: `cal-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + type, + name: String(name).slice(0, 120), + url: parsed.toString(), + user: user || null, + enabled: enabled !== false, + createdAt: new Date().toISOString(), + }; + if (pass || token) { + await credentialManager.store(`calendar:source:${source.id}`, + JSON.stringify({ pass: pass || null, token: token || null }), + { label: `Calendar source ${name}`, createdAt: source.createdAt }); + } + await calendarStateManager.addItem(source); + log.info('calendar', 'Source added', { id: source.id, type, url: source.url }); + ok(res, { source }); + }, 'calendar-add-source')); + + router.put('/sources/:id', asyncHandler(async (req, res) => { + const existing = await findSource(req.params.id); + if (!existing) return errorResponse(res, 404, '[DC-404] Calendar source not found'); + const updates = {}; + for (const field of ['name', 'url', 'enabled']) { + if (req.body[field] !== undefined) updates[field] = req.body[field]; + } + if (updates.url) { + try { new URL(updates.url); } catch (_) { throw new ValidationError('[DC-405] url must be an absolute URL'); } + } + if (req.body.pass !== undefined || req.body.token !== undefined) { + await credentialManager.store(`calendar:source:${existing.id}`, + JSON.stringify({ pass: req.body.pass ?? null, token: req.body.token ?? null }), + { label: `Calendar source ${existing.name}`, updatedAt: new Date().toISOString() }); + } + await calendarStateManager.updateItem(existing.id, updates); + ok(res, { updated: true }); + }, 'calendar-update-source')); + + router.delete('/sources/:id', asyncHandler(async (req, res) => { + const existing = await findSource(req.params.id); + if (!existing) return errorResponse(res, 404, '[DC-404] Calendar source not found'); + await calendarStateManager.removeItem(existing.id); + try { await credentialManager.delete(`calendar:source:${existing.id}`); } catch (_) { /* already gone */ } + ok(res, { removed: true }); + }, 'calendar-remove-source')); + + // ---- Events ------------------------------------------------------------- + + router.get('/events', asyncHandler(async (req, res) => { + const now = new Date(); + const start = req.query.start ? new Date(String(req.query.start)) : new Date(now.getTime() - 7 * 86400000); + const end = req.query.end ? new Date(String(req.query.end)) : new Date(now.getTime() + 30 * 86400000); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + throw new ValidationError('[DC-405] start/end must be ISO dates'); + } + if (end - start > 400 * 86400000) throw new ValidationError('[DC-405] range exceeds 400 days'); + + const sources = (await calendarStateManager.read()).filter(s => s.enabled !== false); + const wanted = req.query.sourceId ? sources.filter(s => s.id === req.query.sourceId) : sources; + + const results = await Promise.allSettled(wanted.map(async (source) => { + if (source.type === 'ics') { + const resp = await fetchT(source.url, { timeout: 15000 }); + if (!resp.ok) throw new Error(`ICS fetch HTTP ${resp.status}`); + const text = await resp.text(); + const parsed = parseIcs(text, { start, end }); + return { sourceId: source.id, name: source.name, events: parsed.events, errors: parsed.errors }; + } + // caldav — resolve secret at call time + let auth = { user: source.user || '', pass: '' }; + try { + const raw = await credentialManager.retrieve(`calendar:source:${source.id}`); + if (raw) { + const creds = JSON.parse(raw); + if (creds.token) auth = { token: creds.token }; + else auth = { user: source.user || '', pass: creds.pass || '' }; + } + } catch (_) { /* fall through with empty pass */ } + const out = await fetchEventsInRange(source.url, auth, start, end); + return { sourceId: source.id, name: source.name, events: out.events, errors: out.errors }; + })); + + const perSource = []; + const events = []; + const errors = []; + for (const r of results) { + if (r.status === 'fulfilled') { + perSource.push({ sourceId: r.value.sourceId, name: r.value.name, count: r.value.events.length, ok: true }); + events.push(...r.value.events); + errors.push(...r.value.errors.map(e => `${r.value.name}: ${e}`)); + } else { + const src = wanted[results.indexOf(r)]; + perSource.push({ sourceId: src ? src.id : null, name: src ? src.name : 'unknown', count: 0, ok: false }); + errors.push(`${src ? src.name : 'source'}: ${r.reason && r.reason.message}`); + } + } + events.sort((a, b) => new Date(a.start) - new Date(b.start)); + ok(res, { events: events.slice(0, 500), perSource, errors, window: { start: start.toISOString(), end: end.toISOString() } }); + }, 'calendar-events')); + + // ---- Radicale auto-provision (the zero-config path) --------------------- + + router.post('/radicale/provision', asyncHandler(async (req, res) => { + const { subdomain, serviceUrl, containerId } = req.body || {}; + if (!subdomain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain)) { + throw new ValidationError('[DC-405] valid subdomain required'); + } + + // Verify the Radicale container actually exists (we only auto-wire what we deployed) + if (docker && containerId) { + try { + const c = docker.client.getContainer(containerId); + const info = await c.inspect(); + if (!info || /radicale/i.test(info.Config?.Image || '') === false) { + log.warn('calendar', 'Provision requested for non-Radicale image', { image: info.Config?.Image }); + } + } catch (e) { + log.warn('calendar', 'Container inspect failed during provision (continuing)', { error: e.message }); + } + } + + const user = 'dashcaddy'; + const pass = generatePassword(); + const configDir = `${CONFIG_DIR_ROOT}/config`; + await writeRadicaleConfig(configDir, { user, pass }); + + const url = serviceUrl || buildServiceUrl(subdomain); + const source = await registerAutoWiredSource({ + calendarStateManager, credentialManager, subdomain, + serviceUrl: url, creds: { user, pass }, templateId: 'radicale', + }); + log.info('calendar', 'Radicale auto-wired', { subdomain, sourceId: source.id }); + ok(res, { + source, + message: 'Calendar auto-wired. Your appointments appear on the dashboard automatically.', + }); + }, 'calendar-radicale-provision')); + + // ---- Discovery probe (Settings modal "Test connection") ------------------ + + router.post('/discover', asyncHandler(async (req, res) => { + const { url, user, pass, token } = req.body || {}; + if (!url) throw new ValidationError('[DC-405] url required'); + try { + const result = await probeCalendar(url, token ? { token } : { user: user || '', pass: pass || '' }); + return ok(res, { probe: result }); + } catch (e) { + return ok(res, { probe: { ok: false, error: e.message } }); + } + }, 'calendar-discover')); + + return router; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 7297798..e31ef2c 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -94,6 +94,8 @@ const sitesRoutes = require('../routes/sites'); const credentialsRoutes = require('../routes/credentials'); const arrRoutes = require('../routes/arr'); const appsRoutes = require('../routes/apps'); +// DC-161: calendar widget backend (CalDAV/ICS sources + Radicale auto-wiring) +const calendarRoutes = require('../routes/calendar'); const logsRoutes = require('../routes/logs'); const backupsRoutes = require('../routes/backups'); const caRoutes = require('../routes/ca'); @@ -159,6 +161,10 @@ async function createApp() { // Create state managers const servicesStateManager = new StateManager(config.SERVICES_FILE); const configStateManager = new StateManager(config.CONFIG_FILE); + // DC-161: calendar sources live next to services.json (same dataDir guarantees) + const calendarStateManager = new StateManager( + require('path').join(require('path').dirname(config.SERVICES_FILE), 'calendar-sources.json') + ); // DC-053: share-store. Single shared instance, lazy file creation on first // write. Lives alongside user-store/invite-store semantics (defensive @@ -917,6 +923,18 @@ async function createApp() { })); apiRouter.use(arrRoutes(ctx)); apiRouter.use(appsRoutes(ctx)); + // DC-161: calendar — sources CRUD, aggregated events, Radicale auto-provision + apiRouter.use('/calendar', calendarRoutes({ + calendarStateManager, + credentialManager: ctx.credentialManager, + docker: ctx.docker, + asyncHandler: ctx.asyncHandler, + ok: ctx.ok, + errorResponse: ctx.errorResponse, + log: ctx.log, + fetchT: ctx.fetchT, + buildServiceUrl: ctx.buildServiceUrl, + })); apiRouter.use('/openclaw', openClawRoutes(ctx)); apiRouter.use(logsRoutes({ asyncHandler: ctx.asyncHandler, diff --git a/dashcaddy-api/src/calendar/caldav-client.js b/dashcaddy-api/src/calendar/caldav-client.js new file mode 100644 index 0000000..88bc1b7 --- /dev/null +++ b/dashcaddy-api/src/calendar/caldav-client.js @@ -0,0 +1,170 @@ +/** + * DC-161: Minimal CalDAV (RFC 4791) client over node:https — zero new deps. + * + * Enough of CalDAV for read-only dashboard use: + * - PROPFIND to discover the calendar collection + display name + * - REPORT (calendar-query) with a time-range filter to fetch VEVENTs + * + * Auth: Basic (Radicale default) or Bearer (addition for other servers). + * Security: HTTPS enforced unless CALDAV_ALLOW_HTTP=1 (localhost testing); + * credentials are never logged; response bodies are size-capped. + */ + +'use strict'; + +const https = require('https'); +const http = require('http'); +const { URL } = require('url'); +const { parseIcs } = require('./ics-parser'); + +const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB cap on any single response +const ALLOW_HTTP = process.env.CALDAV_ALLOW_HTTP === '1'; + +/** + * Perform an HTTP request against the CalDAV server. + * @param {string} method HTTP verb (PROPFIND / REPORT / OPTIONS) + * @param {string} urlStr absolute https:// URL + * @param {{user?: string, pass?: string, token?: string}} auth + * @param {string} [body] request body + * @param {Object} [extraHeaders] + * @returns {Promise<{status: number, headers: Object, body: string}>} + */ +function davRequest(method, urlStr, auth, body, extraHeaders) { + return new Promise((resolve, reject) => { + let u; + try { u = new URL(urlStr); } catch (e) { return reject(new Error(`Invalid CalDAV URL: ${e.message}`)); } + if (u.protocol === 'http:' && !ALLOW_HTTP && !['127.0.0.1', 'localhost', '::1'].includes(u.hostname)) { + return reject(new Error('[DC-401] Refusing plaintext HTTP to a non-local CalDAV server')); + } + const mod = u.protocol === 'http:' ? http : https; + const headers = { + ...extraHeaders, + }; + if (auth && auth.token) { + headers.Authorization = `Bearer ${auth.token}`; + } else if (auth && auth.user !== undefined) { + headers.Authorization = 'Basic ' + Buffer.from(`${auth.user}:${auth.pass || ''}`).toString('base64'); + } + if (body) headers['Content-Length'] = Buffer.byteLength(body); + + const req = mod.request(u, { method, headers, timeout: 15000 }, (res) => { + const chunks = []; + let size = 0; + res.on('data', (c) => { + size += c.length; + if (size > MAX_BODY_BYTES) { + req.destroy(); + return reject(new Error('[DC-402] CalDAV response exceeded 5 MiB size cap')); + } + chunks.push(c); + }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') })); + }); + req.on('timeout', () => { req.destroy(); reject(new Error('[DC-403] CalDAV request timed out (15s)')); }); + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +/** Pull textContent of the first matching element (namespace-agnostic localName). */ +function firstText(xml, localName) { + const re = new RegExp(`<([\\w-]+:)?${localName}(?:\\s[^>]*)?>([^<]*)<`, 'i'); + const m = re.exec(xml); + return m ? m[2] : ''; +} + +/** Extract href + ICS body pairs from a multistatus response. */ +function extractCalendarsAndEvents(multistatusXml) { + const out = []; + // Split per (namespace prefixes vary between servers). + const responses = multistatusXml.split(/<\/[\w-]+:response>/i).filter(s => /<[\w-]+:response[\s>]/i.test(s)); + for (const chunk of responses) { + const hrefMatch = /<([\w-]+:)?href[^>]*>([^<]+)]*>([\s\S]*?)<\/([\w-]+:)?calendar-data>/i.exec(chunk); + const displayName = firstText(chunk, 'displayname'); + if (hrefMatch) { + out.push({ href: hrefMatch[2], displayName, calendarData: calDataMatch ? calDataMatch[2] : null }); + } + } + return out; +} + +/** Decode XML entities in ICS payloads embedded in XML. */ +function decodeXmlEntities(s) { + return String(s || '') + .replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(+d)) + .replace(/&/g, '&'); +} + +/** + * Probe a CalDAV server: OPTIONS + PROPFIND on the principal URL. + * @returns {Promise<{ok: boolean, server: string, calendars: Array}>} + */ +async function probeCalendar(urlStr, auth) { + const opt = await davRequest('OPTIONS', urlStr, auth); + const server = opt.headers['server'] || 'unknown'; + const davHeader = opt.headers['dav'] || ''; + if (opt.status === 401) return { ok: false, status: 401, server, error: 'Authentication failed (401)' }; + if (opt.status >= 400) return { ok: false, status: opt.status, server, error: `Server returned ${opt.status}` }; + + // PROPFIND for display name of the collection at this URL + const propfindBody = ``; + const pf = await davRequest('PROPFIND', urlStr, auth, propfindBody, { Depth: '0', 'Content-Type': 'application/xml' }); + const entries = extractCalendarsAndEvents(pf.body || ''); + const name = (entries[0] && entries[0].displayName) || ''; + const isCalendar = /calendar/i.test(pf.body || ''); + return { ok: true, status: 200, server, dav: davHeader, displayName: name, looksLikeCalendar: isCalendar }; +} + +/** + * Fetch events in a time range via REPORT calendar-query. + * @param {string} urlStr calendar collection URL + * @param {Object} auth {user, pass} or {token} + * @param {Date} start + * @param {Date} end + * @returns {Promise<{events: Array, errors: string[], httpStatus: number}>} + */ +async function fetchEventsInRange(urlStr, auth, start, end) { + const iso = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + const reportBody = ` + + + + + + + + + + + + +`; + + const res = await davRequest('REPORT', urlStr, auth, reportBody, { + Depth: '1', 'Content-Type': 'application/xml; charset=utf-8', + }); + if (res.status === 401) return { events: [], errors: ['Authentication failed (401)'], httpStatus: 401 }; + if (res.status >= 400) return { events: [], errors: [`CalDAV server returned HTTP ${res.status}`], httpStatus: res.status }; + + const entries = extractCalendarsAndEvents(res.body); + const errors = []; + const events = []; + for (const entry of entries) { + if (!entry.calendarData) continue; + const ics = decodeXmlEntities(entry.calendarData); + const parsed = parseIcs(ics, { start, end }); + for (const ev of parsed.events) { + ev.href = entry.href; + events.push(ev); + } + errors.push(...parsed.errors); + } + events.sort((a, b) => new Date(a.start) - new Date(b.start)); + return { events: events.slice(0, 500), errors, httpStatus: res.status }; +} + +module.exports = { davRequest, probeCalendar, fetchEventsInRange, extractCalendarsAndEvents, decodeXmlEntities }; diff --git a/dashcaddy-api/src/calendar/ics-parser.js b/dashcaddy-api/src/calendar/ics-parser.js new file mode 100644 index 0000000..778305f --- /dev/null +++ b/dashcaddy-api/src/calendar/ics-parser.js @@ -0,0 +1,329 @@ +/** + * DC-161: Minimal dependency-free iCalendar (RFC 5545) parser. + * + * Scope: exactly what the calendar widget needs — VEVENT extraction with + * start/end, all-day flags, title, location, description, and bounded + * RRULE expansion for the common recurrence cases (DAILY, WEEKLY, + * MONTHLY, YEARLY) with COUNT/UNTIL/INTERVAL and EXDATE handling. + * + * Deliberately NOT supported (skipped safely): VTODO/VJOURNAL, VALARM + * (skipped, not treated as events), RDATE, complex BYSETPOS/BYSYEARDAY + * rules (rule ignored → base occurrence only), recurrence overrides via + * RECURRENCE-ID (applied as replacements when present). + */ + +'use strict'; + +const MAX_EVENTS = 500; + +/** Unfold continuation lines (CRLF followed by space/tab) per RFC 5545 §3.1. */ +function unfoldLines(text) { + return String(text || '') + .replace(/\r\n/g, '\n') + .replace(/\n[ \t]/g, '') + .split('\n'); +} + +/** Parse "NAME;PARAM=VAL;PARAM2=" into { name, params }. Values may be quoted. */ +function parseContentLine(line) { + // Find the colon that terminates the name/params section — but ignore + // colons inside double-quoted param values. + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') inQuotes = !inQuotes; + else if (ch === ':' && !inQuotes) { + const head = line.slice(0, i); + const value = line.slice(i + 1); + const headParts = head.split(';'); + const name = headParts[0].toUpperCase(); + const params = {}; + for (let p = 1; p < headParts.length; p++) { + const eq = headParts[p].indexOf('='); + if (eq === -1) continue; + const key = headParts[p].slice(0, eq).toUpperCase(); + let val = headParts[p].slice(eq + 1); + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + params[key] = val; + } + return { name, params, value }; + } + } + return null; +} + +/** Unescape TEXT values per RFC 5545 §3.3.11. */ +function unescapeText(v) { + return String(v || '') + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +/** + * Parse an iCalendar date/date-time value. + * Forms: 20261007T140000Z (UTC), 20261007T140000 (floating/local), + * 20261007 (all-day). Returns Date or null. TZID params are resolved to + * their literal wall-clock time (converted via TZ offset when the host + * supports it); floating times are treated as server-local. + */ +function parseIcsDate(value, params) { + const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(String(value || '').trim()); + if (!m) return null; + const [, y, mo, d, hh, mm, ss, z] = m; + if (!hh) { + // All-day date — anchor at local midnight. + return new Date(Number(y), Number(mo) - 1, Number(d)); + } + if (z) return new Date(Date.UTC(+y, +mo - 1, +d, +hh, +mm, +ss)); + if (params && params.TZID) { + return parseZidDate(y, mo, d, hh, mm, ss, params.TZID); + } + return new Date(+y, +mo - 1, +d, +hh, +mm, +ss); +} + +/** Resolve a TZID-qualified wall-clock time via Intl; falls back to floating. */ +function parseZidDate(y, mo, d, hh, mm, ss, tzid) { + try { + const iso = `${y}-${mo}-${d}T${hh}:${mm}:${ss}`; + const offsetProbe = new Date(`${iso}Z`); + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone: tzid, hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + const parts = {}; + for (const p of dtf.formatToParts(offsetProbe)) parts[p.type] = p.value; + const asUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day, + +parts.hour === 24 ? 0 : +parts.hour, +parts.minute, +parts.second); + const offset = asUtc - offsetProbe.getTime(); + return new Date(offsetProbe.getTime() - offset); + } catch (_) { + // Invalid TZID → floating + return new Date(+y, +mo - 1, +d, +hh, +mm, +ss); + } +} + +/** Parse an RRULE string into a plain object with uppercased keys. */ +function parseRrule(rruleSrc) { + const rule = {}; + for (const part of String(rruleSrc || '').replace(/^RRULE:/i, '').split(';')) { + const eq = part.indexOf('='); + if (eq > 0) rule[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1); + } + return rule; +} + +/** Parse BYDAY=MO,WE entries into [{nth, day}]. */ +function parseByday(rule) { + return (rule.BYDAY || '').split(',').filter(Boolean).map(s => { + const m = /^([+-]?\d{1,2})?([A-Z]{2})$/.exec(s.trim()); + return m ? { nth: m[1] ? parseInt(m[1], 10) : null, day: m[2] } : null; + }).filter(Boolean); +} + +/** True when `date` matches none of the event's EXDATE entries (±1s). */ +function notExcluded(ev, date) { + return !(ev.exdates || []).some(d => Math.abs(d - date) < 1000); +} + +/** Build an occurrence in the output list if it overlaps the window. */ +function pushOccurrence(ev, occ, duration, rangeStart, rangeEnd, out) { + if (occ >= rangeStart && occ <= rangeEnd && notExcluded(ev, occ)) { + out.push({ ...ev, start: new Date(occ), end: duration ? new Date(occ.getTime() + duration) : null, recurring: true }); + } +} + +/** Advance a Date by whole months/years without day-overflow surprises. */ +function addMonths(date, n) { + const d = new Date(date); + d.setMonth(d.getMonth() + n); + return d; +} +function addYears(date, n) { + const d = new Date(date); + d.setFullYear(d.getFullYear() + n); + return d; +} + +/** Monday of the week containing `date`. */ +function weekStartOf(date) { + const d = new Date(date); + d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); + return d; +} + +/** Emit every BYDAY occurrence inside one week; returns count consumed. */ +function emitWeekByday(ev, cursor, byday, ctx, out) { + const { count, until, rangeStart, rangeEnd, duration } = ctx; + const weekStart = weekStartOf(cursor); + // Monday-anchored offsets from the week-start date (weekStart is a Monday) + const dayCodes = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 }; + let emitted = 0; + for (const bd of byday) { + if (!(bd.day in dayCodes)) continue; + const occ = new Date(weekStart); + occ.setDate(occ.getDate() + dayCodes[bd.day]); + occ.setHours(cursor.getHours(), cursor.getMinutes(), cursor.getSeconds(), 0); + if (emitted >= count - ctx.emitted) break; + if (until && occ > until) break; + // Occurrences generated by the rule consume COUNT even when EXDATE removes them. + emitted++; + pushOccurrence(ev, occ, duration, rangeStart, rangeEnd, out); + } + const nextWeek = new Date(weekStart); + nextWeek.setDate(nextWeek.getDate() + 7 * ctx.interval); + return { emitted, next: nextWeek }; +} + +/** + * Flatten an event's recurrence into concrete occurrences overlapping the window. + */ +function expandRecurrence(ev, rangeStart, rangeEnd, out) { + const rule = parseRrule(ev.rrule); + const freq = (rule.FREQ || '').toUpperCase(); + const interval = Math.max(1, parseInt(rule.INTERVAL, 10) || 1); + const count = rule.COUNT ? parseInt(rule.COUNT, 10) : Infinity; + const until = rule.UNTIL ? parseIcsDate(rule.UNTIL, {}) : null; + const duration = ev.end ? ev.end - ev.start : 0; + const byday = freq === 'WEEKLY' ? parseByday(rule) : []; + + const ctx = { count, until, rangeStart, rangeEnd, duration, interval, emitted: 0 }; + let cursor = new Date(ev.start); + + for (let guard = 0; guard < 1000; guard++) { + if (ctx.emitted >= count) break; + if (until && cursor > until) break; + if (cursor > rangeEnd) break; + + if (freq === 'WEEKLY' && byday.length > 0) { + const { emitted, next } = emitWeekByday(ev, cursor, byday, ctx, out); + ctx.emitted += emitted; + cursor = new Date(next); + } else if (freq === 'DAILY') { + // COUNT counts rule-generated instances; EXDATE then filters the output. + ctx.emitted++; + pushOccurrence(ev, cursor, duration, rangeStart, rangeEnd, out); + cursor = new Date(cursor.getTime() + interval * 86400000); + } else if (freq === 'MONTHLY') { + ctx.emitted++; + pushOccurrence(ev, cursor, duration, rangeStart, rangeEnd, out); + cursor = addMonths(cursor, interval); + } else if (freq === 'YEARLY') { + ctx.emitted++; + pushOccurrence(ev, cursor, duration, rangeStart, rangeEnd, out); + cursor = addYears(cursor, interval); + } else { + // Unsupported/absent FREQ → emit base occurrence only. + if (cursor >= rangeStart && cursor <= rangeEnd) { + out.push({ ...ev, recurring: false }); + } + return; + } + } +} + +/** Materialize one raw event into `events` (recurrence-aware). */ +function materializeEvent(ev, rangeStart, rangeEnd, events, errors) { + if (!ev.start || Number.isNaN(ev.start.getTime())) { + errors.push(`event "${ev.title || ev.uid}" has invalid DTSTART`); + return; + } + if (ev.rrule) { + expandRecurrence(ev, rangeStart, rangeEnd, events); + } else if (ev.start <= rangeEnd && (ev.end || ev.start) >= rangeStart) { + events.push({ ...ev, recurring: false }); + } +} + +/** Apply RECURRENCE-ID overrides onto the expanded occurrence list. */ +function applyRecurrenceOverrides(rawEvents, events) { + const overrides = rawEvents.filter(e => e.recurrenceId); + for (const ov of overrides) { + const target = events.find(e => e.uid && ov.uid && e.uid === ov.uid && + Math.abs(new Date(e.start).getTime() - ov.recurrenceId.getTime()) < 1000); + if (target) { + target.title = ov.title || target.title; + target.location = ov.location !== undefined ? ov.location : target.location; + target.description = ov.description !== undefined ? ov.description : target.description; + target.start = ov.start; + target.end = ov.end !== undefined ? ov.end : target.end; + } + } +} + +/** + * Parse an ICS string into calendar events. + * @param {string} ics raw iCalendar text + * @param {{start: Date, end: Date}} range window; recurring events are expanded into it + * @returns {{events: Array, errors: string[]}} + */ +function parseIcs(ics, range) { + const errors = []; + const rangeStart = range && range.start ? new Date(range.start) : new Date(Date.now() - 30 * 86400000); + const rangeEnd = range && range.end ? new Date(range.end) : new Date(Date.now() + 60 * 86400000); + + const rawEvents = collectRawEvents(unfoldLines(ics)); + + const events = []; + for (const ev of rawEvents) { + if (events.length >= MAX_EVENTS) break; + materializeEvent(ev, rangeStart, rangeEnd, events, errors); + } + + applyRecurrenceOverrides(rawEvents, events); + + events.sort((a, b) => new Date(a.start) - new Date(b.start)); + return { events: events.slice(0, MAX_EVENTS), errors }; +} + +/** Walk unfolded lines and collect raw VEVENT records. */ +function collectRawEvents(lines) { + const rawEvents = []; + let cur = null; + for (const line of lines) { + if (!line.trim()) continue; + const parsed = parseContentLine(line); + if (!parsed) continue; + const { name, params, value } = parsed; + if (name === 'BEGIN' && value.toUpperCase() === 'VEVENT') { cur = { params: {} }; continue; } + if (name === 'END' && value.toUpperCase() === 'VEVENT') { + // Keep events with a bad DTSTART too so they surface in `errors` + if (cur && (cur.start || cur.badStart)) rawEvents.push(cur); + cur = null; + continue; + } + if (!cur) continue; + assignEventProperty(cur, name, params, value); + } + return rawEvents; +} + +/** Store one content line onto the accumulating VEVENT record. */ +function assignEventProperty(cur, name, params, value) { + switch (name) { + case 'UID': cur.uid = value.trim(); break; + case 'SUMMARY': cur.title = unescapeText(value).trim(); break; + case 'LOCATION': cur.location = unescapeText(value).trim(); break; + case 'DESCRIPTION': cur.description = unescapeText(value).trim(); break; + case 'DTSTART': + cur.start = parseIcsDate(value, params); + cur.allDay = !/T\d{6}/.test(value.trim()); + if (!cur.start) cur.badStart = true; + break; + case 'DTEND': cur.end = parseIcsDate(value, params); break; + case 'RRULE': cur.rrule = value; break; + case 'EXDATE': + cur.exdates = (cur.exdates || []).concat( + value.split(',').map(v => parseIcsDate(v, params)).filter(Boolean) + ); + break; + case 'RECURRENCE-ID': + cur.recurrenceId = parseIcsDate(value, params); + break; + default: break; // VALARM sub-components are skipped via their own BEGIN/END + } +} + +module.exports = { parseIcs, parseIcsDate, unfoldLines, expandRecurrence, MAX_EVENTS }; diff --git a/dashcaddy-api/src/calendar/radicale-provisioner.js b/dashcaddy-api/src/calendar/radicale-provisioner.js new file mode 100644 index 0000000..c8ce42c --- /dev/null +++ b/dashcaddy-api/src/calendar/radicale-provisioner.js @@ -0,0 +1,166 @@ +/** + * DC-161: Radicale auto-provisioner — the zero-config auto-wiring core. + * + * When DashCaddy deploys Radicale through the App Selector, this module: + * 1. Generates a strong calendar password (never shown in a deploy form) + * 2. Writes the Radicale config (htpasswd auth) + htpasswd file into the + * mounted config volume BEFORE the container starts + * 3. Stores the credentials in the DashCaddy credential manager under + * `calendar:radicale:` + * 4. Registers a calendar source so the dashboard calendar widget finds + * it automatically on first render — no user input at all + * + * The htpasswd hash is APR1 (MD5-crypt) — the format Radicale's default + * `radicale.auth.type = htpasswd` accepts, generated with Node's crypto + * (no external tools at deploy time). + */ + +'use strict'; + +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const RADICALE_CONFIG_TEMPLATE = `# Generated by DashCaddy DC-161 auto-provisioner — safe to regenerate +[server] +hosts = 0.0.0.0:5232 + +[auth] +type = htpasswd +htpasswd_filename = /config/users +htpasswd_encryption = md5 + +[rights] +type = owner_only + +[storage] +filesystem_folder = /data/collections + +[web] +type = internal +`; + +function generatePassword() { + // 24 chars from a 62-char alphabet ≈ 142 bits — beyond brute-force reach + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const bytes = crypto.randomBytes(24); + let out = ''; + for (const b of bytes) out += alphabet[b % alphabet.length]; + return out; +} + +/** APR1 (Apache MD5-crypt) hash — pure Node, matches `openssl passwd -apr1`. */ +function apr1Hash(password, salt) { + let saltStr = String(salt || ''); + if (!saltStr) { + // Random 8-char salt from the APR1 alphabet when none supplied + const alphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const bytes = crypto.randomBytes(8); + saltStr = Array.from(bytes, b => alphabet[b % alphabet.length]).join(''); + } + saltStr = saltStr.slice(0, 8); + const pw = Buffer.from(String(password), 'utf8'); + const s = Buffer.from(saltStr, 'ascii'); + + // Step 2: B = md5(pw + salt + pw) + const bDigest = crypto.createHash('md5').update(pw).update(s).update(pw).digest(); + + // Step 1 + 3 + 4: ONE rolling hash — pw, "$apr1$", salt, then B folded in + // 16 bytes at a time (tail truncated), then the "weird" loop feeding + // B[0] on odd bits and pw[0] on even bits. (md5(md5(x)||y) ≠ MD5Update — + // the accumulator must CONTINUE, never re-digest.) + const ctx = crypto.createHash('md5').update(pw).update('$apr1$').update(s); + let i = pw.length; + while (i > 0) { + ctx.update(i > 16 ? bDigest : bDigest.subarray(0, i)); + i -= 16; + } + // Canonical APR (crypto/apr_md5.c): final is memset to ZERO before the + // weird loop, so odd bits feed a literal 0x00 byte; even bits feed pw[0]. + i = pw.length; + while (i > 0) { + ctx.update(i & 1 ? Buffer.from([0]) : pw.subarray(0, 1)); + i >>= 1; + } + let final = ctx.digest(); + + // 1000-iteration round: odd → (pw, final), even → (final, pw). + for (let k = 0; k < 1000; k++) { + const h = crypto.createHash('md5'); + if (k & 1) h.update(pw); else h.update(final); + if (k % 3) h.update(s); + if (k % 7) h.update(pw); + if (k & 1) h.update(final); else h.update(pw); + final = h.digest(); + } + + return `$apr1$${saltStr}$${toBase64Apr1(final)}`; +} + +function toBase64Apr1(digest) { + const chars = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const groups = [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]]; + let out = ''; + for (const [a, b, c] of groups) { + let n = (digest[a] << 16) | (digest[b] << 8) | digest[c]; + for (let k = 0; k < 4; k++) { out += chars[n & 0x3f]; n >>= 6; } + } + const n = digest[11]; + out += chars[n & 0x3f]; + out += chars[(n >> 6) & 0x3f]; + return out; +} + +/** + * Write (idempotently) the Radicale config + htpasswd into the host-side + * config volume directory. Called before container start; if the files + * already exist with the same content, nothing is written. + * @param {string} configDir host path bound to /config inside the container + * @param {{user: string, pass: string}} creds + * @returns {Promise<{written: boolean, configDir: string}>} + */ +async function writeRadicaleConfig(configDir, { user, pass }) { + if (!configDir || path.basename(configDir) === '/') throw new Error('Radicale config dir missing'); + fsSync.mkdirSync(configDir, { recursive: true }); + const configFile = path.join(configDir, 'config'); + const usersFile = path.join(configDir, 'users'); + + const hash = apr1Hash(pass); + const userLine = `${user}:${hash}\n`; + + await fs.writeFile(configFile, RADICALE_CONFIG_TEMPLATE, 'utf8'); + await fs.writeFile(usersFile, userLine, { encoding: 'utf8', mode: 0o600 }); + return { written: true, configDir }; +} + +/** + * After a successful Radicale deploy: register the calendar source so the + * widget auto-discovers it, and persist credentials. + * @param {Object} deps + * @returns {Promise<{source: Object}>} + */ +async function registerAutoWiredSource({ calendarStateManager, credentialManager, subdomain, serviceUrl, creds, templateId = 'radicale' }) { + const username = creds.user; + const credKey = `calendar:${templateId}:${subdomain}`; + await credentialManager.store(credKey, JSON.stringify({ user: username, pass: creds.pass }), { + label: `Calendar (${templateId}) — ${subdomain}`, auto: true, createdAt: new Date().toISOString(), + }); + + const source = { + id: `auto-${templateId}-${subdomain}`, + type: 'caldav', + name: `Calendar (${subdomain})`, + url: `${String(serviceUrl).replace(/\/$/, '')}/${username}/`, + user: username, + autoProvisioned: true, + templateId, + subdomain, + enabled: true, + createdAt: new Date().toISOString(), + }; + await calendarStateManager.addItem(source); + return source; +} + +module.exports = { generatePassword, apr1Hash, writeRadicaleConfig, registerAutoWiredSource, RADICALE_CONFIG_TEMPLATE }; diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js index de535c6..92312ab 100644 --- a/dashcaddy-api/src/context/index.js +++ b/dashcaddy-api/src/context/index.js @@ -33,6 +33,7 @@ function assembleContext({ // State managers servicesStateManager, configStateManager, + calendarStateManager, // DC-053 share store shareStore, @@ -194,6 +195,7 @@ function assembleContext({ // State managers servicesStateManager, configStateManager, + calendarStateManager, // DC-053 share store shareStore, diff --git a/dashcaddy-api/src/docker/app-templates.js b/dashcaddy-api/src/docker/app-templates.js index 48f8002..bbb4b5a 100644 --- a/dashcaddy-api/src/docker/app-templates.js +++ b/dashcaddy-api/src/docker/app-templates.js @@ -251,6 +251,38 @@ const APP_TEMPLATES = { }, // === PRODUCTIVITY & TOOLS === + + // DC-161: Radicale — lightweight CalDAV/CardDAV server. When deployed via + // DashCaddy the calendar widget auto-wires to it (see routes/calendar.js): + // the deploy hook provisions config+credentials and registers the source. + "radicale": { + name: "Radicale Calendar", + description: "Lightweight self-hosted calendar (CalDAV) and contacts (CardDAV) server. Auto-connects to the DashCaddy calendar widget.", + icon: "📅", + category: "Productivity", + popularity: 68, + difficulty: "Easy", + docker: { + image: "tomsquest/docker-radicale:latest", + ports: ["{{PORT}}:5232"], + volumes: [ + "/opt/radicale/config:/config", + "/opt/radicale/data:/data" + ], + environment: {} + }, + subdomain: "calendar", + defaultPort: 5232, + healthCheck: "/", + subpathSupport: 'none', + setupInstructions: [ + "Calendar is auto-wired into the DashCaddy dashboard widget", + "Add the CalDAV URL to your phone/computer calendar apps: https:////", + "Manage sharing and additional users from the Radicale web interface" + ], + secrets: [] + }, + "nextcloud": { name: "Nextcloud", description: "Self-hosted productivity platform and file sync", diff --git a/status/build.js b/status/build.js index 4470cde..9851a23 100644 --- a/status/build.js +++ b/status/build.js @@ -81,6 +81,9 @@ const bundles = { JS('deploys.js'), JS('weather.js'), JS('clock.js'), + // DC-161: calendar widget — top-bar display + sources modal. Reads + // /api/v1/calendar/*; auto-shows Radicale calendars deployed via DashCaddy. + JS('calendar-widget.js'), JS('card-badges.js'), JS('theme-builder.js'), JS('license.js'), diff --git a/status/css/dashboard.css b/status/css/dashboard.css index 42128bc..379ca5d 100644 --- a/status/css/dashboard.css +++ b/status/css/dashboard.css @@ -4200,3 +4200,50 @@ button:focus-visible { font-size: clamp(1rem, 6vw, 1.4rem); } } + +/* ===== Calendar Widget (DC-161) — mirrors weather-widget placement ===== */ +.calendar-widget-container { + display: flex; + align-items: center; + margin-bottom: 0; + flex-shrink: 1; +} + +.calendar-widget { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: transparent; + cursor: pointer; + min-width: 0; + font-size: 0.85rem; + color: var(--fg); + transition: background 0.2s ease, border-color 0.2s ease; +} + +.calendar-widget:hover, +.calendar-widget:focus-visible { + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: var(--accent); + outline: none; +} + +.calendar-widget-icon { + font-size: 1.05rem; + line-height: 1; +} + +.calendar-widget-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +/* Top-row position variants — calendar sits after the clock widget */ +.top-row[data-logo-pos="left"] .calendar-widget-container { order: 3; } +.top-row[data-logo-pos="center"] .calendar-widget-container { order: 3; } +.top-row[data-logo-pos="right"] .calendar-widget-container { order: 3; margin-top: -80px; } diff --git a/status/index.html b/status/index.html index b3df2d9..ed1e0e5 100644 --- a/status/index.html +++ b/status/index.html @@ -132,6 +132,15 @@ + + +
+
+
📅
+ Calendar + +
+
diff --git a/status/js/calendar-widget.js b/status/js/calendar-widget.js new file mode 100644 index 0000000..b5f7e74 --- /dev/null +++ b/status/js/calendar-widget.js @@ -0,0 +1,246 @@ +// ========== CALENDAR WIDGET (DC-161) ========== +// Zero-config for calendars deployed through DashCaddy (Radicale auto-wiring); +// manual CalDAV/ICS sources via the ⚙️ settings modal. Weather-widget pattern: +// compact top-bar display + weather-modal shell + server-backed settings. +(function() { + const errorHandler = new ErrorHandler(); + + injectModal('calendar-modal', `
+
+

📅 Calendar

+
+
Next 14 days
+
+
No upcoming appointments.
+
+
+
+ +
+
+ + Add a calendar (CalDAV URL or ICS feed) +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+
+
+
`); + + const widgetEls = () => ({ + icon: document.querySelector('.calendar-widget-icon'), + text: document.querySelector('.calendar-widget-text'), + }); + + function fmtWhen(start, allDay) { + const d = new Date(start); + const today = new Date(); today.setHours(0, 0, 0, 0); + const day = new Date(d); day.setHours(0, 0, 0, 0); + const days = Math.round((day - today) / 86400000); + const dayLabel = days === 0 ? 'Today' : days === 1 ? 'Tomorrow' : + d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + const timeLabel = allDay ? 'all-day' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + return `${dayLabel} ${timeLabel}`; + } + + async function fetchCalendar() { + const start = new Date(Date.now() - 86400000); + const end = new Date(Date.now() + 14 * 86400000); + const resp = await fetch(`/api/v1/calendar/events?start=${start.toISOString()}&end=${end.toISOString()}`, { cache: 'no-store' }); + if (resp.status === 404) return { notConfigured: true }; + if (!resp.ok) throw new Error(`Calendar API HTTP ${resp.status}`); + return resp.json(); + } + + function renderWidget(body) { + const els = widgetEls(); + if (!els.icon || !els.text) return; + const events = (body && Array.isArray(body.events)) ? body.events : []; + if (!events.length) { + els.icon.textContent = '📅'; + els.text.textContent = 'No calendar'; + return; + } + const next = events[0]; + els.icon.textContent = next.allDay ? '🗓️' : '⏰'; + els.text.innerHTML = `${escapeHtml(next.title || 'Untitled')} + · ${escapeHtml(fmtWhen(next.start, next.allDay))}`; + } + + function renderUpcoming(body) { + const list = document.getElementById('calendar-upcoming-list'); + if (!list) return; + const events = (body && Array.isArray(body.events)) ? body.events : []; + if (!events.length) { + list.innerHTML = '
No upcoming appointments.
'; + return; + } + list.innerHTML = events.slice(0, 12).map(ev => + `
+ ${escapeHtml(fmtWhen(ev.start, ev.allDay))} + ${escapeHtml(ev.title || 'Untitled')} + ${ev.location ? `${escapeHtml(ev.location)}` : ''} +
`).join(''); + } + + async function renderSources() { + const listEl = document.getElementById('calendar-sources-list'); + if (!listEl) return; + let sources = []; + try { + const resp = await fetch('/api/v1/calendar/sources', { cache: 'no-store' }); + if (resp.ok) { + const body = await resp.json(); + sources = (body && Array.isArray(body.sources)) ? body.sources + : (Array.isArray(body) ? body : []); + } + } catch (e) { /* modal still usable */ } + if (!sources.length) { + listEl.innerHTML = '
No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.
'; + return; + } + listEl.innerHTML = sources.map(s => ` +
+ ${escapeHtml(s.name || s.id)} + · ${escapeHtml(s.type)}${s.autoProvisioned ? ' · auto-wired' : ''} + + +
`).join(''); + listEl.querySelectorAll('.cal-src-toggle').forEach(btn => btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const enable = btn.dataset.enabled !== '1'; + await fetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: enable }) + }); + renderSources(); refresh(); + })); + listEl.querySelectorAll('.cal-src-del').forEach(btn => btn.addEventListener('click', async () => { + const id = btn.dataset.id; + await fetch(`/api/v1/calendar/sources/${encodeURIComponent(id)}`, { method: 'DELETE' }); + renderSources(); refresh(); + })); + } + + async function refresh() { + if (refresh._inFlight) return; + refresh._inFlight = true; + try { + const body = await fetchCalendar(); + renderWidget(body); + if (document.getElementById('calendar-modal').style.display === 'flex') { + renderUpcoming(body); + } + } catch (e) { + errorHandler.log(e, 'calendar-widget'); + const els = widgetEls(); + if (els.text) els.text.textContent = 'Calendar offline'; + } finally { + refresh._inFlight = false; + } + } + + // ---- Settings modal wiring ---- + function openModal() { + document.getElementById('calendar-modal').style.display = 'flex'; + renderSources(); + refresh(); + } + function closeModal() { + document.getElementById('calendar-modal').style.display = 'none'; + } + + function formCreds() { + return { + type: document.getElementById('cal-add-type').value, + name: document.getElementById('cal-add-name').value.trim(), + url: document.getElementById('cal-add-url').value.trim(), + user: document.getElementById('cal-add-user').value.trim(), + pass: document.getElementById('cal-add-pass').value, + }; + } + + function setStatus(msg) { + document.getElementById('cal-add-status').textContent = msg; + } + + document.getElementById('cal-add-test').addEventListener('click', async () => { + const f = formCreds(); + if (!f.url) { setStatus('Enter a URL first.'); return; } + setStatus('Testing…'); + try { + const resp = await fetch('/api/v1/calendar/discover', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: f.url, user: f.user, pass: f.pass }) + }); + const body = await resp.json(); + const p = body && body.probe; + if (p && p.ok) setStatus(`✓ Reachable (${p.server || 'server'})${p.looksLikeCalendar ? ' — looks like a calendar' : ''}`); + else setStatus(`✗ ${p && p.error ? p.error : 'Not reachable'}`); + } catch (e) { + setStatus(`✗ ${e.message}`); + } + }); + + document.getElementById('cal-add-save').addEventListener('click', async () => { + const f = formCreds(); + if (!f.name || !f.url) { setStatus('Name and URL are required.'); return; } + try { + const resp = await fetch('/api/v1/calendar/sources', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: f.name, type: f.type, url: f.url, user: f.user, pass: f.pass }) + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + setStatus(`✗ ${body.error || body.message || `HTTP ${resp.status}`}`); + return; + } + setStatus('Saved.'); + document.getElementById('cal-add-details').open = false; + ['cal-add-name', 'cal-add-url', 'cal-add-user', 'cal-add-pass'].forEach(id => { document.getElementById(id).value = ''; }); + renderSources(); refresh(); + } catch (e) { + setStatus(`✗ ${e.message}`); + } + }); + + const calBtn = document.getElementById('calendar-widget'); + if (calBtn) calBtn.addEventListener('click', openModal); + const calSettings = document.getElementById('calendar-settings'); + if (calSettings) calSettings.addEventListener('click', (e) => { e.stopPropagation(); openModal(); }); + const calClose = document.getElementById('calendar-close'); + if (calClose) calClose.addEventListener('click', closeModal); + + window.refreshCalendarWidget = refresh; + + const POLL = (typeof DC !== 'undefined' && DC.POLL && DC.POLL.CALENDAR) || 300000; + setInterval(refresh, POLL); + setTimeout(refresh, 400); +})(); diff --git a/status/js/globals.js b/status/js/globals.js index 29592ae..b2fbd8b 100644 --- a/status/js/globals.js +++ b/status/js/globals.js @@ -24,6 +24,7 @@ const DC = { LOGS: 3000, // 3s — log viewer updates STATS: (typeof window !== 'undefined' && window.__DC_STATS_OVERRIDE) || 5000, // 5s default — resource monitor refresh (overridable via Settings → Health) WEATHER: 600000, // 10m — weather widget refresh + CALENDAR: 300000, // 5m — calendar widget refresh (DC-161) HEALTH: 1000, // 1s — card health badge refresh DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy },