From 5466506ffb2e2e1b2d024145f217271edf6c809a Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:08:41 -0400 Subject: [PATCH] Calendar: add events (NE Hub calendar + iCloud) and a Podcast tab - Add, edit and delete events; month/agenda views open them on tap - NE Hub calendar stored on the server and published as a private ICS subscribe link - iCloud CalDAV read/write with an app-specific password (encrypted at rest, only *.icloud.com contacted, event addresses checked against the calendar) - Edits patch the existing entry so alarms/attendees survive; writes use If-Match / If-None-Match - Time zone definitions (VTIMEZONE) generated so other apps read times correctly - Podcast tab (PODCAST_URL) alongside Podcast Admin Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 1 + .gitignore | 1 + Dockerfile | 2 +- README.md | 8 ++- cal.js | 92 +++++++++++++++++++++++-- caldav.js | 129 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 1 + ics.js | 167 +++++++++++++++++++++++++++++++++++++++++++++ public/app.js | 161 +++++++++++++++++++++++++++++++++++++++---- public/style.css | 6 ++ server.js | 4 +- 11 files changed, 550 insertions(+), 22 deletions(-) create mode 100644 caldav.js create mode 100644 ics.js diff --git a/.env.example b/.env.example index 23b0654..0991883 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,4 @@ AUDIOBOOKS_URL=https://audiobooks.necloud.us # First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists. HUB_SETUP_CODE= VERSE_URL=https://versebyversewithnate.us/admin +PODCAST_URL=http://172.16.16.106:8383/ diff --git a/.gitignore b/.gitignore index 3ac63ef..649b654 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env data/ node_modules/ +.claude/ diff --git a/Dockerfile b/Dockerfile index 976319a..044e38e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev -COPY server.js auth.js cal.js ./ +COPY server.js auth.js cal.js ics.js caldav.js ./ COPY public ./public ENV PORT=8080 DATA_DIR=/data RUN mkdir -p /data && chown node:node /data diff --git a/README.md b/README.md index ac6d9c9..87fb5ad 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,16 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile. - **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume). - **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation. -- **Calendar**: agenda from private ICS links (Google secret address, Outlook/M365 published link, iCloud public calendar). Links are stored on the server in `/data`. +- **Calendar**: month grid and agenda. Add, edit and delete events: + - **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link + (Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there). + - **iCloud (two-way)**: connect with an Apple ID and an *app-specific password* (appleid.apple.com). Credentials are + encrypted in `/data/icloud.json`; only `*.icloud.com` is contacted. Edits keep alarms and attendees intact. + - **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only. - **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. - **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the queue (play next / add to queue) and position are remembered. - **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap. +- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. - **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose). Providers forbid framing, and a real unified inbox would need Google/Azure app registrations. diff --git a/cal.js b/cal.js index 542a4fd..e776d34 100644 --- a/cal.js +++ b/cal.js @@ -5,6 +5,8 @@ const path = require('path'); const dns = require('dns').promises; const net = require('net'); const crypto = require('crypto'); +const ics = require('./ics'); +const caldav = require('./caldav'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const FILE = path.join(DATA_DIR, 'calendars.json'); @@ -105,6 +107,7 @@ function parseEvents(text) { case 'DURATION': cur.dur = parseDuration(l.value); break; case 'SUMMARY': cur.title = unesc(l.value); break; case 'LOCATION': cur.location = unesc(l.value); break; + case 'DESCRIPTION': cur.notes = unesc(l.value); break; case 'UID': cur.uid = l.value; break; case 'STATUS': cur.status = l.value.toUpperCase(); break; case 'RRULE': cur.rrule = Object.fromEntries(l.value.split(';').map(x => x.split('='))); break; @@ -199,8 +202,8 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD if (s.allDay) { const sd = dateStr(startMs), ed = dateStr(Math.max(endMs, startMs + 864e5)); if (ed <= win.fd || sd > win.td) return; - out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed }); - } else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs }); + out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: true, start: sd, end: ed, uid: ev.uid, series: !!ev.rrule }); + } else if (endMs >= win.from && startMs <= win.to) out.push({ title: ev.title || '(No title)', location: ev.location || '', allDay: false, start: startMs, end: endMs, uid: ev.uid, series: !!ev.rrule }); }; if (!ev.rrule || ev.recId) { add(s.ms); continue; } let guard = 0; @@ -214,6 +217,28 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD return out; } +// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed. +const EV_FILE = path.join(DATA_DIR, 'events.json'), FEED_FILE = path.join(DATA_DIR, 'feed.json'); +const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const writeJ = (f, v) => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(f, JSON.stringify(v, null, 2), { mode: 0o600 }); }; +const loadLocal = () => readJ(EV_FILE, []); +const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' }; +function feedToken(reset) { + let t = readJ(FEED_FILE, {}).token; + if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); } + return t; +} +const localIcs = () => ics.buildCalendar(loadLocal().map(e => ({ uid: e.uid, fields: e.fields }))); + +/** GET /cal/feed/.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret. */ +function feed(req, res) { + const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname); + const want = Buffer.from(feedToken()), got = Buffer.from(m ? m[1] : ''); + if (!m || want.length !== got.length || !crypto.timingSafeEqual(want, got)) { res.writeHead(404); return res.end('Not found'); } + res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' }); + res.end(localIcs()); +} + // ---- HTTP API (called by server.js, behind the passkey gate) const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; @@ -222,7 +247,7 @@ const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d' async function handle(req, res) { const u = new URL(req.url, 'http://x'); try { - if (u.pathname === '/api/cal/list') return json(res, 200, load().map(({ id, name, color }) => ({ id, name, color }))); + if (u.pathname === '/api/cal/list') return json(res, 200, [{ ...LOCAL, local: true }, ...caldav.enabled().map(c => ({ ...c, icloud: true })), ...load().map(({ id, name, color }) => ({ id, name, color }))]); if (u.pathname === '/api/cal/add' && req.method === 'POST') { const { name, url } = JSON.parse(await readBody(req)); if (!url) throw new Error('Paste the calendar link'); @@ -246,10 +271,69 @@ async function handle(req, res) { try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); } catch (e) { errors.push(`${c.name}: ${e.message}`); } })); + try { + for (const r of await caldav.listRaw(win.from, win.to)) + for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: r.color, editable: true, href: r.href, etag: r.etag }); + } catch (e) { errors.push('iCloud: ' + e.message); } + try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); } + catch (e) { errors.push('NE Hub: ' + e.message); } return json(res, 200, { events: all, errors }); } + if (u.pathname === '/api/cal/event' && req.method === 'GET') { + const q = u.searchParams, tz = q.get('tz') || 'UTC'; + if (q.get('cal') === 'local') { + const e = loadLocal().find(x => x.uid === q.get('uid')); + if (!e) throw new Error('Event not found'); + return json(res, 200, { cal: 'local', uid: e.uid, fields: e.fields }); + } + if (caldav.isCal(q.get('cal'))) { + const r = await caldav.getResource(q.get('cal'), q.get('href')); + const master = parseEvents(r.ics).find(e => !e.recId); + if (!master) throw new Error('Could not read that event'); + return json(res, 200, { cal: q.get('cal'), href: q.get('href'), etag: r.etag, fields: ics.fieldsFromEvent(master, tz) }); + } + return json(res, 400, { error: 'Unknown calendar' }); + } + if (u.pathname === '/api/cal/event' && req.method === 'POST') { + const b = JSON.parse(await readBody(req)); + if (b.cal === 'local') { + ics.validate(b.fields); + const all = loadLocal(), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid); + const rec = { uid, fields: b.fields, updated: Date.now() }; + if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() }); + writeJ(EV_FILE, all); + return json(res, 200, { ok: true, uid }); + } + if (caldav.isCal(b.cal)) { + ics.validate(b.fields); + if (b.href) { // edit: patch the latest copy so alarms, attendees etc. survive + const cur = await caldav.getResource(b.cal, b.href); + await caldav.putEvent(b.cal, null, ics.patchIcs(cur.ics, b.fields), { href: b.href, etag: cur.etag }); + return json(res, 200, { ok: true }); + } + const uid = ics.newUid(); + await caldav.putEvent(b.cal, uid, ics.buildCalendar([{ uid, fields: b.fields }], 'NE Hub')); + return json(res, 200, { ok: true, uid }); + } + throw new Error('Unknown calendar'); + } + if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') { + const b = JSON.parse(await readBody(req)); + if (b.cal === 'local') { writeJ(EV_FILE, loadLocal().filter(x => x.uid !== b.uid)); return json(res, 200, { ok: true }); } + if (caldav.isCal(b.cal)) { await caldav.deleteEvent(b.cal, b.href, b.etag); return json(res, 200, { ok: true }); } + throw new Error('Unknown calendar'); + } + if (u.pathname === '/api/cal/icloud' && req.method === 'GET') return json(res, 200, caldav.status()); + if (u.pathname === '/api/cal/icloud/connect' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); return json(res, 200, await caldav.connect(b.appleId, b.password)); } + if (u.pathname === '/api/cal/icloud/toggle' && req.method === 'POST') { const b = JSON.parse(await readBody(req)); return json(res, 200, caldav.toggle(b.id, b.enabled)); } + if (u.pathname === '/api/cal/icloud/disconnect' && req.method === 'POST') { caldav.wipe(); return json(res, 200, { ok: true }); } + if (u.pathname === '/api/cal/feed') { + const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`; + if (req.method === 'POST') feedToken(true); // reset: the old link stops working + return json(res, 200, { url: `${origin}/cal/feed/${feedToken()}.ics` }); + } json(res, 404, { error: 'Not found' }); } catch (e) { json(res, 400, { error: e.message }); } } -module.exports = { handle, parseEvents, expand, zonedToUtc, assertPublic }; +module.exports = { handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; diff --git a/caldav.js b/caldav.js new file mode 100644 index 0000000..2f74947 --- /dev/null +++ b/caldav.js @@ -0,0 +1,129 @@ +// iCloud calendar access over CalDAV, using an Apple ID + app-specific password (no app registration). +// Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. +// Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted. +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const auth = require('./auth'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const FILE = path.join(DATA_DIR, 'icloud.json'); +const TEST = process.env.CALDAV_TEST === '1'; // lets the tests talk to a local stand-in server; never set in production +const BASE = TEST ? process.env.CALDAV_BASE : 'https://caldav.icloud.com'; + +// ---- Encrypted credential store +const key = () => crypto.scryptSync(auth.secret, 'nehub-icloud', 32); +const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv); + const d = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); }; +const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12)); + d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); }; +function load() { try { return unseal(JSON.parse(fs.readFileSync(FILE, 'utf8')).data); } catch { return null; } } +function save(o) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify({ data: seal(o) }), { mode: 0o600 }); } +function wipe() { try { fs.unlinkSync(FILE); } catch {} } + +// ---- HTTP + XML +const allowed = u => TEST ? ['localhost', '127.0.0.1'].includes(u.hostname) : u.protocol === 'https:' && (u.hostname === 'icloud.com' || u.hostname.endsWith('.icloud.com')); +async function dav(method, url, { user, pass, depth, body, headers = {} } = {}) { + let u = new URL(url); + for (let hop = 0; hop < 4; hop++) { + if (!allowed(u)) throw new Error('Refusing to contact ' + u.hostname); + const r = await fetch(u, { method, redirect: 'manual', signal: AbortSignal.timeout(20000), body, + headers: { Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'), ...(depth !== undefined ? { Depth: String(depth) } : {}), + ...(body ? { 'Content-Type': 'application/xml; charset=utf-8' } : {}), ...headers } }); + if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; } + return { status: r.status, text: await r.text(), headers: r.headers, url: u.toString() }; + } + throw new Error('Too many redirects'); +} +const decode = s => s.replace(//g, (_, x) => x.replace(/&/g, '&').replace(//g, '>')) + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, '\r').replace(/ /g, '\n').replace(/&/g, '&'); +const tag = (xml, name) => { const m = new RegExp(`<(?:\\w+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)`, 'i').exec(xml); return m ? m[1] : null; }; +const responses = xml => (xml.match(/<(?:\w+:)?response(?:\s[^>]*)?>[\s\S]*?<\/(?:\w+:)?response>/gi) || []); +const hrefOf = block => { const h = tag(block, 'href'); return h ? decode(h).trim() : null; }; +const abs = (href, base) => new URL(href, base).toString(); + +const PROPFIND_PRINCIPAL = ''; +const PROPFIND_HOME = ''; +const PROPFIND_CALS = ''; +const stamp = ms => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); +const REPORT = (from, to) => ` +`; + +const calId = href => 'icloud:' + crypto.createHash('sha1').update(href).digest('hex').slice(0, 10); + +/** Sign in, find the calendars, and remember everything (encrypted). Throws a readable message on failure. */ +async function connect(appleId, password) { + const o = { user: String(appleId || '').trim(), pass: String(password || '').replace(/\s/g, '') }; + if (!o.user || !o.pass) throw new Error('Enter your Apple ID and an app-specific password'); + const a = await dav('PROPFIND', BASE + '/', { ...o, depth: 0, body: PROPFIND_PRINCIPAL }); + if (a.status === 401 || a.status === 403) throw new Error('Apple rejected that Apple ID or app-specific password. It must be an app-specific password, not your normal one.'); + if (a.status >= 400) throw new Error('iCloud answered with an error (' + a.status + ')'); + const principalHref = hrefOf(tag(a.text, 'current-user-principal') || ''); + if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)'); + const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME }); + const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || ''); + if (!homeHref) throw new Error('Could not find the calendar home for this account'); + const home = abs(homeHref, b.url); + const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS }); + const calendars = []; + for (const r of responses(c.text)) { + const rt = tag(r, 'resourcetype') || ''; + if (!/<(?:\w+:)?calendar\s*\/?>/i.test(rt)) continue; + const comps = tag(r, 'supported-calendar-component-set'); + if (comps && !/name="VEVENT"/i.test(comps)) continue; // reminders lists etc. + const href = abs(hrefOf(r), c.url); + const color = (tag(r, 'calendar-color') || '').trim().slice(0, 7); + calendars.push({ id: calId(href), href, name: decode(tag(r, 'displayname') || 'Calendar').trim() || 'Calendar', color: /^#[0-9a-f]{6}$/i.test(color) ? color : '#d9453a', enabled: true }); + } + if (!calendars.length) throw new Error('No calendars found on this account'); + save({ user: o.user, pass: o.pass, calendars }); + return status(); +} + +const status = () => { const s = load(); return s ? { connected: true, appleId: s.user, calendars: s.calendars.map(({ id, name, color, enabled }) => ({ id, name, color, enabled })) } : { connected: false }; }; +function toggle(id, enabled) { const s = load(); if (!s) throw new Error('Not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); c.enabled = !!enabled; save(s); return status(); } +const enabled = () => { const s = load(); return s ? s.calendars.filter(c => c.enabled).map(({ id, name, color }) => ({ id, name, color })) : []; }; +const inside = (c, href) => { if (!String(href || '').startsWith(c.href.replace(/\/?$/, '/'))) throw new Error('That event is not in this calendar'); }; +const cred = id => { const s = load(); if (!s) throw new Error('iCloud is not connected'); const c = s.calendars.find(x => x.id === id); if (!c) throw new Error('Calendar not found'); return { s, c, o: { user: s.user, pass: s.pass } }; }; + +/** Raw calendar entries overlapping the window: [{ calId, name, color, href, etag, ics }] */ +async function listRaw(from, to) { + const s = load(); if (!s) return []; + const out = []; + await Promise.all(s.calendars.filter(c => c.enabled).map(async c => { + const r = await dav('REPORT', c.href, { user: s.user, pass: s.pass, depth: 1, body: REPORT(from - 864e5, to + 864e5) }); + if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.'); + if (r.status >= 400) throw new Error(`${c.name}: iCloud answered ${r.status}`); + for (const blk of responses(r.text)) { + const data = tag(blk, 'calendar-data'); if (!data) continue; + out.push({ calId: c.id, name: c.name, color: c.color, href: abs(hrefOf(blk), r.url), etag: decode(tag(blk, 'getetag') || '').trim(), ics: decode(data) }); + } + })); + return out; +} + +async function getResource(id, href) { + const { c, o } = cred(id); inside(c, href); + const r = await dav('GET', href, o); + if (r.status === 404) throw new Error('That event no longer exists'); + if (r.status >= 400) throw new Error('iCloud answered ' + r.status); + return { ics: r.text, etag: r.headers.get('etag') || '' }; +} +async function putEvent(id, uid, icsText, { href, etag } = {}) { + const { c, o } = cred(id); + if (href) inside(c, href); + const url = href || new URL(encodeURIComponent(String(uid).split('@')[0]) + '.ics', c.href.replace(/\/?$/, '/')).toString(); + const r = await dav('PUT', url, { ...o, body: icsText, headers: { 'Content-Type': 'text/calendar; charset=utf-8', ...(href ? (etag ? { 'If-Match': etag } : {}) : { 'If-None-Match': '*' }) } }); + if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.'); + if (r.status === 401) throw new Error('iCloud sign-in no longer works. Reconnect with a new app-specific password.'); + if (r.status >= 400) throw new Error('iCloud refused the change (' + r.status + ')'); + return { href: url, etag: r.headers.get('etag') || '' }; +} +async function deleteEvent(id, href, etag) { + const { c, o } = cred(id); inside(c, href); + const r = await dav('DELETE', href, { ...o, headers: etag ? { 'If-Match': etag } : {} }); + if (r.status === 412) throw new Error('This event was changed somewhere else. Reload the calendar and try again.'); + if (r.status >= 400 && r.status !== 404) throw new Error('iCloud refused the delete (' + r.status + ')'); +} + +module.exports = { connect, status, toggle, enabled, listRaw, getResource, putEvent, deleteEvent, wipe, isCal: id => /^icloud:[0-9a-f]{10}$/.test(id || '') }; diff --git a/docker-compose.yml b/docker-compose.yml index e8fa5f7..e05d1b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us} AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us} VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin} + PODCAST_URL: ${PODCAST_URL:-http://172.16.16.106:8383/} ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here) HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log diff --git a/ics.js b/ics.js new file mode 100644 index 0000000..fb1302b --- /dev/null +++ b/ics.js @@ -0,0 +1,167 @@ +// Building and editing iCalendar (ICS) text: used for the NE Hub calendar, its subscribe feed, and iCloud writes. +// +// "fields" is what the app's event form edits: +// { title, location, notes, allDay, start: 'YYYY-MM-DDTHH:mm' | 'YYYY-MM-DD', end: (same; all-day end is inclusive), +// tz: IANA zone, repeat: 'none' | 'daily' | 'weekdays' | 'weekly' | 'monthly' | 'yearly' | 'keep' } +const crypto = require('crypto'); + +const pad = n => String(n).padStart(2, '0'); +const esc = v => String(v || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); +const fold = line => { // 75 octets per line, continuation lines start with a space + if (Buffer.byteLength(line) <= 75) return line; + const out = []; let cur = '', bytes = 0; + for (const ch of line) { + const b = Buffer.byteLength(ch), limit = out.length ? 74 : 75; + if (bytes + b > limit) { out.push(cur); cur = ''; bytes = 0; } + cur += ch; bytes += b; + } + out.push(cur); + return out.join('\r\n '); +}; +const ymdhms = (d, t) => d.replace(/-/g, '') + (t ? 'T' + t.replace(/:/g, '').padEnd(6, '0') : ''); +const nowStamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); +const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return !!tz; } catch { return false; } }; + +// ---- Time zones +const fmtCache = new Map(); +function partsAt(ms, tz) { + let f = fmtCache.get(tz); + if (!f) fmtCache.set(tz, f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })); + return Object.fromEntries(f.formatToParts(new Date(ms)).map(x => [x.type, +x.value])); +} +const offsetMin = (ms, tz) => { const p = partsAt(ms, tz); return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 6e4); }; +const fmtOff = m => (m < 0 ? '-' : '+') + pad(Math.floor(Math.abs(m) / 60)) + pad(Math.abs(m) % 60); +/** UTC ms -> 'YYYY-MM-DDTHH:mm' wall clock in tz */ +function wallClock(ms, tz) { const p = partsAt(ms, tz); return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`; } + +// A VTIMEZONE built from the zone's real offset changes in a year, so other apps do not have to guess. +function vtimezone(tz, year = new Date().getUTCFullYear()) { + const L = ['BEGIN:VTIMEZONE', 'TZID:' + tz]; + const changes = []; + let prev = offsetMin(Date.UTC(year, 0, 1), tz); + for (let d = 1; d <= 366; d++) { + const t = Date.UTC(year, 0, 1) + d * 864e5, o = offsetMin(t, tz); + if (o !== prev) { // narrow the change down to the minute + let lo = t - 864e5, hi = t; + while (hi - lo > 6e4) { const mid = Math.floor((lo + hi) / 2); if (offsetMin(mid, tz) === prev) lo = mid; else hi = mid; } + changes.push({ at: hi, from: prev, to: o }); prev = o; + } + } + const abbr = ms => (new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'short' }).formatToParts(new Date(ms)).find(x => x.type === 'timeZoneName') || {}).value || ''; + const comp = (kind, from, to, at) => { + const wall = new Date(at + from * 6e4), dow = wall.getUTCDay(), day = wall.getUTCDate(); + const dim = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate(); + const nth = day + 7 > dim ? -1 : Math.ceil(day / 7); + return ['BEGIN:' + kind, `DTSTART:1970${pad(wall.getUTCMonth() + 1)}01T${pad(wall.getUTCHours())}${pad(wall.getUTCMinutes())}00`.replace(/^(DTSTART:1970\d\d)01/, '$1' + '01'), + `RRULE:FREQ=YEARLY;BYMONTH=${wall.getUTCMonth() + 1};BYDAY=${nth}${['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][dow]}`, + 'TZOFFSETFROM:' + fmtOff(from), 'TZOFFSETTO:' + fmtOff(to), 'TZNAME:' + abbr(at + 6e4), 'END:' + kind]; + }; + if (!changes.length) L.push('BEGIN:STANDARD', 'DTSTART:19700101T000000', 'TZOFFSETFROM:' + fmtOff(prev), 'TZOFFSETTO:' + fmtOff(prev), 'TZNAME:' + abbr(Date.UTC(year, 0, 1)), 'END:STANDARD'); + else for (const c of changes) L.push(...comp(c.to > c.from ? 'DAYLIGHT' : 'STANDARD', c.from, c.to, c.at)); + L.push('END:VTIMEZONE'); + return L; +} + +// ---- Building +const RRULES = { daily: 'FREQ=DAILY', weekdays: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', weekly: 'FREQ=WEEKLY', monthly: 'FREQ=MONTHLY', yearly: 'FREQ=YEARLY' }; + +function validate(f) { + if (!f || !String(f.title || '').trim()) throw new Error('Give the event a title'); + const re = f.allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + if (!re.test(f.start || '') || !re.test(f.end || '')) throw new Error('Start and end are required'); + if (f.end < f.start) throw new Error('The event ends before it starts'); + if (!f.allDay && !validTz(f.tz)) throw new Error('Unknown time zone'); + if (f.repeat && f.repeat !== 'none' && f.repeat !== 'keep' && !RRULES[f.repeat]) throw new Error('Unknown repeat option'); +} + +/** The property lines (unfolded) that the form controls. */ +function propLines(f) { + const L = ['SUMMARY:' + esc(String(f.title).trim())]; + if (f.location) L.push('LOCATION:' + esc(f.location)); + if (f.notes) L.push('DESCRIPTION:' + esc(f.notes)); + if (f.allDay) { + const end = new Date(Date.parse(f.end + 'T00:00:00Z') + 864e5).toISOString().slice(0, 10); // ICS all-day end is exclusive + L.push('DTSTART;VALUE=DATE:' + ymdhms(f.start), 'DTEND;VALUE=DATE:' + ymdhms(end)); + } else if (f.tz === 'UTC') { + L.push('DTSTART:' + ymdhms(...f.start.split('T')) + 'Z', 'DTEND:' + ymdhms(...f.end.split('T')) + 'Z'); + } else { + L.push(`DTSTART;TZID=${f.tz}:` + ymdhms(...f.start.split('T')), `DTEND;TZID=${f.tz}:` + ymdhms(...f.end.split('T'))); + } + if (f.repeat && f.repeat !== 'none' && f.repeat !== 'keep') L.push('RRULE:' + RRULES[f.repeat]); + return L; +} + +function buildVevent(f, uid) { + validate(f); + return ['BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + nowStamp(), 'SEQUENCE:0', ...propLines(f), 'END:VEVENT']; +} + +/** A whole VCALENDAR from [{uid, fields}] */ +function buildCalendar(items, name = 'NE Hub') { + const zones = [...new Set(items.filter(i => !i.fields.allDay && i.fields.tz && i.fields.tz !== 'UTC').map(i => i.fields.tz))]; + const L = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//NE Hub//Calendar//EN', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:' + esc(name)]; + for (const tz of zones) L.push(...vtimezone(tz)); + for (const i of items) L.push(...buildVevent(i.fields, i.uid)); + L.push('END:VCALENDAR'); + return L.map(fold).join('\r\n') + '\r\n'; +} + +/** Edit an existing resource in place: only the properties the form controls change, everything else (alarms, attendees…) stays. */ +function patchIcs(text, f) { + validate(f); + const lines = text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter(l => l !== ''); + const out = [], drop = new Set(['SUMMARY', 'LOCATION', 'DESCRIPTION', 'DTSTART', 'DTEND', 'DURATION']); + if (f.repeat !== 'keep') drop.add('RRULE'); + let inEv = false, done = false, hasTz = lines.some(l => l === 'BEGIN:VTIMEZONE' && lines.some(x => x === 'TZID:' + f.tz)); + let master = false, seq = 0, sawStamp = false; + // find out whether this VEVENT is the master (no RECURRENCE-ID) before editing it + const evBlocks = []; let s = -1; + lines.forEach((l, i) => { if (l === 'BEGIN:VEVENT') s = i; if (l === 'END:VEVENT' && s >= 0) { evBlocks.push([s, i]); s = -1; } }); + const target = evBlocks.find(([a, b]) => !lines.slice(a, b).some(l => /^RECURRENCE-ID/.test(l))); + if (!target) throw new Error('Could not find the event in that calendar entry'); + const needTz = !f.allDay && f.tz && f.tz !== 'UTC' && !hasTz; + let depth = 0; // nested components (VALARM) keep their own SUMMARY/DESCRIPTION/TRIGGER lines untouched + lines.forEach((l, i) => { + if (i === target[0]) { if (needTz) out.push(...vtimezone(f.tz)); out.push(l); inEv = true; return; } + if (i === target[1]) { out.push(...propLines(f)); if (!sawStamp) out.push('DTSTAMP:' + nowStamp()); out.push('SEQUENCE:' + (seq + 1)); out.push(l); inEv = false; return; } + if (!inEv || i < target[0] || i > target[1]) { out.push(l); return; } + if (/^BEGIN:/.test(l)) { depth++; out.push(l); return; } + if (/^END:/.test(l)) { depth--; out.push(l); return; } + if (depth > 0) { out.push(l); return; } + const name = l.split(/[;:]/)[0].toUpperCase(); + if (drop.has(name)) return; + if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; } + if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; } + out.push(l); + }); + // a series changed from timed to all-day (or back) leaves stale EXDATEs of the wrong type; drop them + return out.map(fold).join('\r\n') + '\r\n'; +} + +/** Form fields from a parsed master event (see cal.js parseEvents). */ +function fieldsFromEvent(ev, clientTz) { + const s = ev.start, tzOk = tz => tz && tz !== 'UTC' && validTz(tz); + const rr = ev.rrule; + let repeat = 'none'; + if (rr) { + const by = (rr.BYDAY || '').split(',').sort().join(','); + repeat = rr.COUNT || rr.UNTIL || (rr.INTERVAL && rr.INTERVAL !== '1') ? 'keep' + : rr.FREQ === 'DAILY' && !rr.BYDAY ? 'daily' + : rr.FREQ === 'WEEKLY' && by === 'FR,MO,TH,TU,WE' ? 'weekdays' + : rr.FREQ === 'WEEKLY' && !rr.BYMONTH && (!rr.BYDAY || rr.BYDAY.split(',').length === 1) ? 'weekly' + : rr.FREQ === 'MONTHLY' && !rr.BYDAY && !rr.BYMONTHDAY ? 'monthly' + : rr.FREQ === 'YEARLY' && !rr.BYDAY && !rr.BYMONTH ? 'yearly' : 'keep'; + } + const base = { title: ev.title || '', location: ev.location || '', notes: ev.notes || '', repeat }; + const endMs = ev.end ? ev.end.ms : ev.dur != null ? s.ms + ev.dur : s.allDay ? s.ms + 864e5 : s.ms; + if (s.allDay) { + const last = new Date(Math.max(endMs - 864e5, s.ms)).toISOString().slice(0, 10); + return { ...base, allDay: true, start: new Date(s.ms).toISOString().slice(0, 10), end: last, tz: clientTz }; + } + const tz = tzOk(s.tz) ? s.tz : clientTz; // zone-less / UTC times are shown in the viewer's own zone + return { ...base, allDay: false, start: wallClock(s.ms, tz), end: wallClock(endMs, tz), tz }; +} + +const newUid = () => crypto.randomUUID() + '@nehub'; + +module.exports = { buildVevent, buildCalendar, patchIcs, fieldsFromEvent, vtimezone, validate, newUid, wallClock, validTz }; diff --git a/public/app.js b/public/app.js index cc825d8..618a83d 100644 --- a/public/app.js +++ b/public/app.js @@ -39,6 +39,7 @@ const ICONS = { cal: '', search: '', book: '', + mic: '', trash: '', }; const icon = (n, cls = '') => `${ICONS[n]}`; @@ -62,7 +63,7 @@ function mailUrl(m, kind) { return base; // iCloud has no sign-in hint } -let cfg = { verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' }; +let cfg = { podcast: 'http://172.16.16.106:8383/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' }; // ---- Seafile client (via same-origin proxy) const sf = { @@ -96,8 +97,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); // ---- Navigation -const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; -const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' }; +const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audio'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; +const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search' }; const PRIMARY = ['home', 'files', 'music', 'mail']; // phone tab bar; the rest sit behind "More" const phone = matchMedia('(max-width: 767px)'); let activeTab = 'home'; @@ -121,7 +122,7 @@ function openMore() { el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); } -const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit }; +const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit }; let editor; function route() { const [, id = 'home', ...rest] = location.hash.split('/'); @@ -158,6 +159,7 @@ function viewHome() { ${tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda')} ${tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice')} ${tile('#/audio', '#8a5cf0', 'audio', 'Audio', 'Audiobooks')} + ${tile(cfg.podcast, '#c2417a', 'mic', 'Podcast', 'Home network', true)} ${tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true)} ${tile('#/mail', '#4f7cff', 'mail', 'Mail', '4 accounts')} @@ -254,17 +256,21 @@ async function loadRange(start, days) { } const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); }; const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); -const evRow = e => `
${esc(e.title)} - ${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}
`; +const evStore = []; // events currently on screen, so a tap can open the right one +const evRow = e => { const i = evStore.push(e) - 1; + return ``; }; +document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); }); const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1); return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); }; async function renderAgenda(el, mode) { // Home card (today only) if (!el) return; try { - const cals = await jfetch('/api/cal/list'); if (!cals.length) return; + const cals = await jfetch('/api/cal/list'); const { byDay } = await loadAgenda(1); const list = byDay.get(ymd(new Date())) || []; + if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing if (!document.body.contains(el)) return; el.innerHTML = `

Today

${list.length ? list.slice(0, 6).map(evRow).join('') : '
Nothing on the calendar today
'}
`; } catch {} @@ -278,7 +284,9 @@ async function viewCalendar() { const v = $('#view'); v.innerHTML = `

Calendar

${[['month', 'Month'], ['agenda', 'Agenda']].map(([k, l]) => `${l}`).join('')}
+

Loading…

`; + $('#ne').onclick = () => newEvent(mode === 'month' ? calState.sel : ymd(new Date())); v.querySelectorAll('.seg a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); }); $('#mc').onclick = manageCalendars; try { @@ -333,10 +341,98 @@ async function renderMonth() { $('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); }; } +const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +const pad2 = n => String(n).padStart(2, '0'); +const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + +function openEvent(e) { + if (!e) return; + if (e.editable) return editEvent(e); + const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end); + const el = openSheet(e.title, e.cal, `
When${esc(when)}
+ ${e.location ? `
Where${esc(e.location)}
` : ''}
+

This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.

`); + el.querySelector('#sx').onclick = closeSheet; +} + +async function editEvent(e) { + try { + const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href); + const r = await jfetch('/api/cal/event?' + q); + eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series }); + } catch (err) { toast(err.message); } +} + +async function newEvent(day) { + try { + const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud); + const d = new Date(day + 'T00:00:00'), now = new Date(); + if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0); + const end = new Date(d.getTime() + 36e5); + eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } }); + } catch (err) { toast(err.message); } +} + +function eventForm(o) { + const f = { ...o.fields }, editing = o.mode === 'edit'; + const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']]; + if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']); + const calPick = editing ? `
Calendar${esc(o.calName)}
` + : ``; + const el = openSheet(editing ? 'Edit event' : 'New event', '', `
+ +
+ + + + ${calPick} + ${editing && o.series ? '

This repeats. Changes apply to the whole series.

' : ''} +
+ ${editing ? '
' : ''} +
`); + el.querySelector('.sheet').classList.add('wide'); + const draw = () => { // date-only inputs for all-day, date+time otherwise + const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v; + el.querySelector('#fdt').innerHTML = ` + ${!f.allDay && f.tz !== deviceTz() ? `

Times are in ${esc(f.tz)}.

` : ''}`; + const s = el.querySelector('#fs'), en = el.querySelector('#fe2'); + s.onchange = () => { // moving the start keeps the length + const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start); + f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } } + }; + en.onchange = () => { f.end = en.value; }; + }; + draw(); + el.querySelector('#fa').onchange = ev => { + f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end; + f.allDay = ev.target.checked; + if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); } + else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; } + draw(); + }; + el.querySelector('#sx').onclick = closeSheet; + el.querySelector('#ef').onsubmit = async ev => { + ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = ''; + const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay, + start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value }; + const cal = editing ? o.calId : el.querySelector('#fc').value; + try { + await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) }); + if (!editing) store.set('cal_default', cal); + closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar(); + } catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; } + }; + const del = el.querySelector('#fd'); + if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => { + try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); } + catch (err) { toast(err.message); } + }); +} + function addCalendar() { - const el = openSheet('Add a calendar', '', `
+ const el = openSheet('Add a calendar link', '', ` -

Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. iCloud: calendar's Share → Public Calendar → copy link. Anyone with the link can read that calendar, so keep it private.

+

Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.

`); el.querySelector('#sx').onclick = closeSheet; el.querySelector('form').onsubmit = async e => { @@ -345,13 +441,39 @@ function addCalendar() { catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; } }; } + async function manageCalendars() { - const cals = await jfetch('/api/cal/list'); - const el = openSheet('Calendars', '', `
${cals.map(c => `
${esc(c.name)} -
`).join('') || '
No calendars
'}
-
`); - el.querySelector('#sx').onclick = closeSheet; el.querySelector('#ac').onclick = addCalendar; - el.querySelectorAll('button[data-id]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); closeSheet(); viewCalendar(); }); + const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]); + const links = cals.filter(c => !c.local && !c.icloud); + const el = openSheet('Calendars', '', `
+
NE HubEvents you add here
+ ${links.map(c => `
${esc(c.name)}Link (read-only)
`).join('')} +
+
+

iCloud calendars

+ ${ic.connected ? `
${ic.calendars.map(c => ``).join('')}
+

Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.

` + : `

Read and add events in your iCloud calendars. Use an app-specific password (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.

+
+
`} +

See NE Hub events in other apps

+

Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).

+ +
+
`); + el.querySelector('.sheet').classList.add('wide'); + el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar; + el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); }); + el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } }); + const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); }; + const icf = el.querySelector('#icf'); + if (icf) icf.onsubmit = async e => { + e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = ''; + try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); } + catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; } + }; + el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } }; + el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); }); } function viewMail() { @@ -517,6 +639,15 @@ function viewVerse() { `; } +// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is +// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN). +function viewPodcast() { + const url = cfg.podcast, u = new URL(url); + $('#view').innerHTML = `

Podcast

${esc(u.host)}

+

Opens in its own tab. This address is on your home network, so it works when you are at home or connected to your VPN.

+
`; +} + function viewAudio() { const url = cfg.audiobooks, c = abCreds(); $('#view').innerHTML = `

Audio

diff --git a/public/style.css b/public/style.css index ddcd871..9d52707 100644 --- a/public/style.css +++ b/public/style.css @@ -223,3 +223,9 @@ body.onaudio #view { flex: none; overflow: visible; padding-bottom: 8px; } .cday .chips em { font-style: normal; font-size: 12px; line-height: 1.25; padding: 1px 6px; border-radius: 5px; border-left: 3px solid var(--c); background: var(--panel2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; } } + +select, textarea, input[type=date], input[type=datetime-local] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); } +textarea { padding: 12px 14px; resize: vertical; font-family: inherit; } +.chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; } +.chk input { width: 22px; height: 22px; } +.row input[type=checkbox] { width: 22px; height: 22px; } diff --git a/server.js b/server.js index a24911c..753c8dd 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,7 @@ const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.neclou const auth = require('./auth'); const cal = require('./cal'); const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin'; +const PODCAST_URL = process.env.PODCAST_URL || 'http://172.16.16.106:8383/'; const PUBLIC = path.join(__dirname, 'public'); const TYPES = { @@ -274,6 +275,7 @@ if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app ed http.createServer((req, res) => { const pathname = new URL(req.url, 'http://x').pathname; if (pathname.startsWith('/api/auth/')) return auth.handle(req, res); + if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session // Everything else needs a passkey session, except the sign-in page and its assets, // and OnlyOffice's save callback (which carries its own signed token). const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback']; @@ -283,7 +285,7 @@ http.createServer((req, res) => { } if (req.url === '/api/config') { res.writeHead(200, { 'content-type': 'application/json' }); - return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, verse: VERSE_URL, editor: true, jwt: !!JWT_SECRET })); + return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, verse: VERSE_URL, podcast: PODCAST_URL, editor: true, jwt: !!JWT_SECRET })); } if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);