NE Hub: Seafile, OnlyOffice, Plex, calendar and mail in one PWA
build-container / build (push) Canceled after 4m16s
build-container / build (push) Canceled after 4m16s
- Mobile-first PWA (phone tab bar with More menu, iPad/desktop rail) - Passkey (WebAuthn) sign-in gates the whole app; first passkey via setup code - Files: Seafile browse/upload/new doc/rename/move/share link/preview/delete - In-app OnlyOffice editing with JWT-signed configs and save-back to Seafile - Plex music player via plex.tv sign-in and a server-side proxy - Calendar agenda from private ICS links (recurrence + time zones) - Search, Home dashboard, webmail deep links, audiobooks frame - Dockerfile, compose, and a Gitea Actions workflow that builds the image Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
55e0c39171
22 files changed
+2341
No files matched your search
@@ -0,0 +1,255 @@
|
||||
// Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars).
|
||||
// Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dns = require('dns').promises;
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const FILE = path.join(DATA_DIR, 'calendars.json');
|
||||
const load = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return []; } };
|
||||
const save = v => { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(v, null, 2), { mode: 0o600 }); };
|
||||
|
||||
// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses.
|
||||
const isPrivate = ip => {
|
||||
if (net.isIPv6(ip)) { const l = ip.toLowerCase(); return l === '::1' || l.startsWith('fc') || l.startsWith('fd') || l.startsWith('fe80') || l.startsWith('::ffff:') && isPrivate(l.slice(7)); }
|
||||
const [a, b] = ip.split('.').map(Number);
|
||||
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
||||
};
|
||||
async function assertPublic(u) {
|
||||
if (u.protocol !== 'https:') throw new Error('Calendar links must be https (webcal:// is fine)');
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true });
|
||||
if (!addrs.length || addrs.some(a => isPrivate(a.address))) throw new Error('That address is not allowed');
|
||||
}
|
||||
async function fetchIcs(raw) {
|
||||
let u = new URL(raw.replace(/^webcal:/i, 'https:'));
|
||||
for (let hop = 0; hop < 4; hop++) {
|
||||
await assertPublic(u);
|
||||
const r = await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(12000), headers: { Accept: 'text/calendar, */*' } });
|
||||
if (r.status >= 300 && r.status < 400 && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); continue; }
|
||||
if (!r.ok) throw new Error('Calendar server answered ' + r.status);
|
||||
const text = await r.text();
|
||||
if (text.length > 8e6) throw new Error('Calendar file is too large');
|
||||
if (!/BEGIN:VCALENDAR/i.test(text)) throw new Error('That link is not a calendar (ICS) file');
|
||||
return text;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
const cache = new Map(); // url -> { t, text }
|
||||
async function getIcs(url) {
|
||||
const c = cache.get(url);
|
||||
if (c && Date.now() - c.t < 10 * 60e3) return c.text;
|
||||
const text = await fetchIcs(url);
|
||||
cache.set(url, { t: Date.now(), text });
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---- Parsing
|
||||
const WIN_TZ = { 'Eastern Standard Time': 'America/New_York', 'Central Standard Time': 'America/Chicago', 'Mountain Standard Time': 'America/Denver',
|
||||
'Pacific Standard Time': 'America/Los_Angeles', 'US Mountain Standard Time': 'America/Phoenix', 'Alaskan Standard Time': 'America/Anchorage',
|
||||
'Hawaiian Standard Time': 'Pacific/Honolulu', 'GMT Standard Time': 'Europe/London', 'W. Europe Standard Time': 'Europe/Berlin',
|
||||
'Central Europe Standard Time': 'Europe/Budapest', 'Romance Standard Time': 'Europe/Paris', 'UTC': 'UTC' };
|
||||
const validTz = tz => { try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } };
|
||||
const tzName = tz => (tz && (validTz(tz) ? tz : WIN_TZ[tz])) || null;
|
||||
|
||||
const fmts = new Map();
|
||||
// Wall-clock time in a zone -> UTC ms.
|
||||
function zonedToUtc(y, mo, d, h, mi, s, tz) {
|
||||
const guess = Date.UTC(y, mo - 1, d, h, mi, s);
|
||||
if (!tz) return guess;
|
||||
let fmt = fmts.get(tz);
|
||||
if (!fmt) fmts.set(tz, fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }));
|
||||
const offset = t => { const p = Object.fromEntries(fmt.formatToParts(new Date(t)).map(x => [x.type, +x.value])); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - t; };
|
||||
let t = guess - offset(guess);
|
||||
t = guess - offset(t); // second pass settles DST edges
|
||||
return t;
|
||||
}
|
||||
|
||||
function unfold(text) { return text.replace(/\r?\n[ \t]/g, '').split(/\r?\n/); }
|
||||
function parseLine(line) {
|
||||
const i = line.indexOf(':'); if (i < 0) return null;
|
||||
const [name, ...params] = line.slice(0, i).split(';');
|
||||
const p = {}; params.forEach(x => { const [k, v] = x.split('='); p[k.toUpperCase()] = (v || '').replace(/^"|"$/g, ''); });
|
||||
return { name: name.toUpperCase(), params: p, value: line.slice(i + 1) };
|
||||
}
|
||||
const unesc = v => v.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1');
|
||||
|
||||
// "20261020T090000Z" | "20261020T090000" | "20261020" -> { allDay, y, mo, d, h, mi, s, utc, tz }
|
||||
function parseDate(value, params) {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const allDay = !m[4] || params.VALUE === 'DATE';
|
||||
const r = { allDay, y: +m[1], mo: +m[2], d: +m[3], h: +(m[4] || 0), mi: +(m[5] || 0), s: +(m[6] || 0), tz: m[7] ? 'UTC' : tzName(params.TZID) };
|
||||
r.ms = allDay ? Date.UTC(r.y, r.mo - 1, r.d) : zonedToUtc(r.y, r.mo, r.d, r.h, r.mi, r.s, r.tz);
|
||||
return r;
|
||||
}
|
||||
function parseDuration(v) {
|
||||
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(v.trim());
|
||||
if (!m) return null;
|
||||
const sign = m[1] === '-' ? -1 : 1;
|
||||
return sign * (((+m[2] || 0) * 7 + (+m[3] || 0)) * 864e5 + (+m[4] || 0) * 36e5 + (+m[5] || 0) * 6e4 + (+m[6] || 0) * 1e3);
|
||||
}
|
||||
|
||||
function parseEvents(text) {
|
||||
const events = []; let cur = null;
|
||||
for (const line of unfold(text)) {
|
||||
if (line === 'BEGIN:VEVENT') { cur = { ex: [] }; continue; }
|
||||
if (line === 'END:VEVENT') { if (cur && cur.start) events.push(cur); cur = null; continue; }
|
||||
if (!cur) continue;
|
||||
const l = parseLine(line); if (!l) continue;
|
||||
switch (l.name) {
|
||||
case 'DTSTART': cur.start = parseDate(l.value, l.params); break;
|
||||
case 'DTEND': cur.end = parseDate(l.value, l.params); break;
|
||||
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 '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;
|
||||
case 'EXDATE': l.value.split(',').forEach(v => { const d = parseDate(v, l.params); if (d) cur.ex.push(d.allDay ? d.ms : d.ms); }); break;
|
||||
case 'RECURRENCE-ID': cur.recId = parseDate(l.value, l.params); break;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// ---- Expansion
|
||||
const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
||||
const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
function nthWeekday(y, m, dow, n) { // n>0: nth, n<0: nth from end
|
||||
if (n > 0) { const first = new Date(Date.UTC(y, m - 1, 1)).getUTCDay(); const d = 1 + ((dow - first + 7) % 7) + (n - 1) * 7; return d <= daysInMonth(y, m) ? d : null; }
|
||||
const dim = daysInMonth(y, m), last = new Date(Date.UTC(y, m - 1, dim)).getUTCDay();
|
||||
const d = dim - ((last - dow + 7) % 7) + (n + 1) * 7; return d >= 1 ? d : null;
|
||||
}
|
||||
|
||||
// Occurrence start dates as wall-clock {y,mo,d}, from DTSTART forward, within [..until].
|
||||
function* occurrences(ev, windowStartMs, windowEndMs) {
|
||||
const s = ev.start, r = ev.rrule;
|
||||
const tod = { h: s.h, mi: s.mi, s: s.s };
|
||||
const toMs = ({ y, mo, d }) => s.allDay ? Date.UTC(y, mo - 1, d) : zonedToUtc(y, mo, d, tod.h, tod.mi, tod.s, s.tz);
|
||||
if (!r) { yield { y: s.y, mo: s.mo, d: s.d }; return; }
|
||||
const interval = Math.max(1, +r.INTERVAL || 1), count = r.COUNT ? +r.COUNT : Infinity;
|
||||
const until = r.UNTIL ? parseDate(r.UNTIL, {}) : null;
|
||||
const byday = r.BYDAY ? r.BYDAY.split(',').map(x => { const m = /^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(x); return m && { n: m[1] ? +m[1] : 0, dow: DAYS.indexOf(m[2]) }; }).filter(Boolean) : null;
|
||||
const bymd = r.BYMONTHDAY ? r.BYMONTHDAY.split(',').map(Number) : null;
|
||||
const bymonth = r.BYMONTH ? r.BYMONTH.split(',').map(Number) : null;
|
||||
let n = 0;
|
||||
const ok = c => {
|
||||
const ms = toMs(c);
|
||||
return ms >= s.ms && (!until || ms <= until.ms + (until.allDay ? 864e5 - 1 : 0));
|
||||
};
|
||||
const emit = function* (c) { if (!ok(c)) return; if (n >= count) return 'stop'; n++; yield c; };
|
||||
const startDate = Date.UTC(s.y, s.mo - 1, s.d);
|
||||
// Without COUNT we can jump close to the window instead of walking from the original start.
|
||||
let i0 = 0;
|
||||
if (!r.COUNT && windowStartMs > s.ms) {
|
||||
const dayDiff = (windowStartMs - startDate) / 864e5, monthDiff = (new Date(windowStartMs).getUTCFullYear() - s.y) * 12 + new Date(windowStartMs).getUTCMonth() - (s.mo - 1);
|
||||
const steps = { DAILY: dayDiff / interval, WEEKLY: dayDiff / 7 / interval, MONTHLY: monthDiff / interval, YEARLY: monthDiff / 12 / interval }[r.FREQ] || 0;
|
||||
i0 = Math.max(0, Math.floor(steps) - 2);
|
||||
}
|
||||
for (let i = i0; i < i0 + 5000; i++) {
|
||||
let cands = [];
|
||||
if (r.FREQ === 'DAILY') {
|
||||
const dt = new Date(startDate + i * interval * 864e5);
|
||||
cands = [{ y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }];
|
||||
} else if (r.FREQ === 'WEEKLY') {
|
||||
const weekStart = startDate - new Date(startDate).getUTCDay() * 864e5 + i * interval * 7 * 864e5;
|
||||
const dows = byday ? byday.map(b => b.dow) : [new Date(startDate).getUTCDay()];
|
||||
cands = dows.sort((a, b) => a - b).map(dow => { const dt = new Date(weekStart + dow * 864e5); return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() }; });
|
||||
} else if (r.FREQ === 'MONTHLY') {
|
||||
const mIdx = s.mo - 1 + i * interval, y = s.y + Math.floor(mIdx / 12), mo = (mIdx % 12) + 1;
|
||||
if (byday) cands = byday.map(b => b.n ? nthWeekday(y, mo, b.dow, b.n) : null).filter(Boolean).map(d => ({ y, mo, d }));
|
||||
else (bymd || [s.d]).forEach(d => { const dd = d < 0 ? daysInMonth(y, mo) + d + 1 : d; if (dd >= 1 && dd <= daysInMonth(y, mo)) cands.push({ y, mo, d: dd }); });
|
||||
cands.sort((a, b) => a.d - b.d);
|
||||
} else if (r.FREQ === 'YEARLY') {
|
||||
const y = s.y + i * interval;
|
||||
const months = bymonth || [s.mo];
|
||||
for (const mo of months) {
|
||||
if (byday) byday.forEach(b => { const d = b.n ? nthWeekday(y, mo, b.dow, b.n) : null; if (d) cands.push({ y, mo, d }); });
|
||||
else (bymd || [s.d]).forEach(d => { if (d <= daysInMonth(y, mo)) cands.push({ y, mo, d }); });
|
||||
}
|
||||
} else { yield { y: s.y, mo: s.mo, d: s.d }; return; }
|
||||
if (bymonth && r.FREQ !== 'YEARLY') cands = cands.filter(c => bymonth.includes(c.mo));
|
||||
if (!cands.length && i > 4000) return;
|
||||
for (const c of cands) {
|
||||
if (toMs(c) < s.ms) continue;
|
||||
if (toMs(c) > windowEndMs) return;
|
||||
const res = yield* emit(c);
|
||||
if (res === 'stop') return;
|
||||
}
|
||||
if (cands.length && toMs(cands[0]) > windowEndMs) return;
|
||||
if (until && cands.length && toMs(cands[0]) > until.ms + 864e5) return;
|
||||
}
|
||||
}
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const dateStr = ms => { const d = new Date(ms); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; };
|
||||
|
||||
function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD, inclusive) }
|
||||
const out = [];
|
||||
const overrides = new Set(events.filter(e => e.recId).map(e => e.uid + '|' + e.recId.ms));
|
||||
for (const ev of events) {
|
||||
if (ev.status === 'CANCELLED') continue;
|
||||
const s = ev.start;
|
||||
const durMs = ev.end ? ev.end.ms - s.ms : ev.dur != null ? ev.dur : s.allDay ? 864e5 : 0;
|
||||
const add = (startMs) => {
|
||||
const endMs = startMs + durMs;
|
||||
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 });
|
||||
};
|
||||
if (!ev.rrule || ev.recId) { add(s.ms); continue; }
|
||||
let guard = 0;
|
||||
for (const c of occurrences(ev, win.from - 864e5, win.to + 864e5)) {
|
||||
const ms = s.allDay ? Date.UTC(c.y, c.mo - 1, c.d) : zonedToUtc(c.y, c.mo, c.d, s.h, s.mi, s.s, s.tz);
|
||||
if (ev.ex.includes(ms) || overrides.has(ev.uid + '|' + ms)) continue;
|
||||
add(ms);
|
||||
if (++guard > 3000) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- 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)); };
|
||||
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/add' && req.method === 'POST') {
|
||||
const { name, url } = JSON.parse(await readBody(req));
|
||||
if (!url) throw new Error('Paste the calendar link');
|
||||
await getIcs(url); // validates the link before saving it
|
||||
const cals = load();
|
||||
cals.push({ id: crypto.randomBytes(6).toString('hex'), name: String(name || 'Calendar').slice(0, 40), url, color: COLORS[cals.length % COLORS.length] });
|
||||
save(cals);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (u.pathname === '/api/cal/remove' && req.method === 'POST') {
|
||||
const { id } = JSON.parse(await readBody(req));
|
||||
save(load().filter(c => c.id !== id));
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (u.pathname === '/api/cal/events') {
|
||||
const q = u.searchParams;
|
||||
const win = { from: +q.get('from'), to: +q.get('to'), fd: q.get('fd'), td: q.get('td') };
|
||||
if (!win.from || !win.to || !win.fd || !win.td) throw new Error('from/to required');
|
||||
const all = [], errors = [];
|
||||
await Promise.all(load().map(async c => {
|
||||
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}`); }
|
||||
}));
|
||||
return json(res, 200, { events: all, errors });
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { json(res, 400, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { handle, parseEvents, expand, zonedToUtc, assertPublic };
|
||||
Reference in new issue
Block a user