build-container / build (push) Successful in 1m55s
- 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 <noreply@anthropic.com>
298 lines
18 KiB
JavaScript
298 lines
18 KiB
JavaScript
// Tiny dependency-free server: serves the PWA, exposes config, and proxies Seafile
|
|
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { Readable } = require('stream');
|
|
|
|
const PORT = process.env.PORT || 8080;
|
|
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
|
|
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
|
|
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
|
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 = {
|
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
|
|
'.json': 'application/json', '.webmanifest': 'application/manifest+json',
|
|
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
|
|
};
|
|
|
|
async function proxySeafile(req, res) {
|
|
const tu = new URL(SEAFILE_URL + req.url.slice('/sf'.length));
|
|
const inline = tu.searchParams.has('hubinline'); // preview: show in the page instead of forcing a download
|
|
tu.searchParams.delete('hubinline');
|
|
const target = tu.toString();
|
|
const headers = {};
|
|
for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range']) {
|
|
if (req.headers[h]) headers[h] = req.headers[h];
|
|
}
|
|
const hasBody = !['GET', 'HEAD'].includes(req.method);
|
|
try {
|
|
const upstream = await fetch(target, {
|
|
method: req.method, headers, redirect: 'manual',
|
|
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
duplex: hasBody ? 'half' : undefined,
|
|
});
|
|
const out = {};
|
|
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges']) {
|
|
if (inline && h === 'content-disposition') continue;
|
|
if (h === 'content-length' && upstream.headers.get('content-encoding')) continue; // decompressed by fetch, so the length changed
|
|
const v = upstream.headers.get(h);
|
|
if (v) out[h] = v;
|
|
}
|
|
// Rewrite redirects so they stay on the proxy and on the Seafile host.
|
|
const loc = upstream.headers.get('location');
|
|
if (loc) {
|
|
const u = new URL(loc, SEAFILE_URL);
|
|
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
|
|
}
|
|
res.writeHead(upstream.status, out);
|
|
if (upstream.body) Readable.fromWeb(upstream.body).pipe(res); else res.end();
|
|
} catch (e) {
|
|
res.writeHead(502, { 'content-type': 'application/json' });
|
|
const why = e.cause ? (e.cause.code || e.cause.message) : '';
|
|
console.error('Seafile proxy:', target.split('?')[0], e.message, why);
|
|
res.end(JSON.stringify({ error: 'Seafile unreachable: ' + e.message + (why ? ' (' + why + ')' : '') }));
|
|
}
|
|
}
|
|
|
|
function serveStatic(req, res) {
|
|
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
if (p === '/') p = '/index.html';
|
|
if (p === '/login') p = '/login.html';
|
|
const file = path.join(PUBLIC, path.normalize(p));
|
|
if (!file.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); }
|
|
fs.readFile(file, (err, data) => {
|
|
if (err) { res.writeHead(404); return res.end('Not found'); }
|
|
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
|
|
if (p === '/sw.js') headers['cache-control'] = 'no-cache';
|
|
res.writeHead(200, headers);
|
|
res.end(data);
|
|
});
|
|
}
|
|
|
|
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
|
|
const crypto = require('crypto');
|
|
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
|
|
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
|
|
const b64u = b => Buffer.from(b).toString('base64url');
|
|
const jwtSign = p => {
|
|
const d = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + '.' + b64u(JSON.stringify(p));
|
|
return d + '.' + crypto.createHmac('sha256', JWT_SECRET).update(d).digest('base64url');
|
|
};
|
|
const jwtVerify = t => {
|
|
const [h, p, s] = String(t || '').split('.');
|
|
if (!s) return null;
|
|
const want = crypto.createHmac('sha256', JWT_SECRET).update(h + '.' + p).digest();
|
|
const got = Buffer.from(s, 'base64url');
|
|
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
|
|
try { return JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; }
|
|
};
|
|
const sessions = new Map(); // callback id -> { token, repo, path, exp }; keeps the user's Seafile token out of URLs
|
|
setInterval(() => { for (const [k, v] of sessions) if (v.exp < Date.now()) sessions.delete(k); }, 3600e3).unref();
|
|
const DOC_TYPE = { word: 'doc docx odt rtf txt', cell: 'xls xlsx ods csv', slide: 'ppt pptx odp' };
|
|
const docType = e => Object.keys(DOC_TYPE).find(k => DOC_TYPE[k].split(' ').includes(e));
|
|
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 sfGet = async (p, token) => {
|
|
const r = await fetch(SEAFILE_URL + p, { headers: { Authorization: 'Token ' + token } });
|
|
if (!r.ok) throw new Error('Seafile ' + r.status);
|
|
return r.json();
|
|
};
|
|
|
|
async function ooConfig(req, res) {
|
|
const token = (req.headers.authorization || '').replace(/^Token /, '');
|
|
try {
|
|
const { repo, path: p, mobile } = JSON.parse(await readBody(req));
|
|
const [url, detail, me] = await Promise.all([
|
|
sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(p)}&reuse=1`, token),
|
|
sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/detail/?p=${encodeURIComponent(p)}`, token),
|
|
sfGet('/api2/account/info/', token),
|
|
]);
|
|
const name = p.split('/').pop(), fileExt = name.split('.').pop().toLowerCase();
|
|
if (!docType(fileExt)) return json(res, 400, { error: 'Unsupported file type' });
|
|
const id = crypto.randomUUID();
|
|
sessions.set(id, { token, repo, path: p, exp: Date.now() + 12 * 3600e3 });
|
|
const origin = PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}`;
|
|
const config = {
|
|
document: { fileType: fileExt, title: name, url, key: crypto.createHash('sha1').update(repo + p + detail.mtime).digest('hex').slice(0, 20) },
|
|
documentType: docType(fileExt),
|
|
editorConfig: { mode: 'edit', callbackUrl: `${origin}/api/oo/callback?id=${id}`, user: { id: me.email, name: me.name || me.email }, lang: 'en' },
|
|
type: mobile ? 'mobile' : 'desktop', height: '100%', width: '100%',
|
|
};
|
|
if (JWT_SECRET) config.token = jwtSign(config); // unsigned only works when the Document Server has JWT disabled
|
|
json(res, 200, config);
|
|
} catch (e) { json(res, 502, { error: e.message }); }
|
|
}
|
|
|
|
async function ooCallback(req, res) {
|
|
const id = new URL(req.url, 'http://x').searchParams.get('id');
|
|
try {
|
|
const raw = JSON.parse(await readBody(req));
|
|
const auth = (req.headers.authorization || '').replace(/^Bearer /, '');
|
|
const body = JWT_SECRET ? jwtVerify(raw.token || auth) : raw;
|
|
if (!body) return json(res, 403, { error: 1 });
|
|
const s = sessions.get(id);
|
|
// status 2 = ready to save, 6 = force-saved
|
|
if (s && (body.status === 2 || body.status === 6)) {
|
|
const dir = s.path.slice(0, s.path.lastIndexOf('/')) || '/';
|
|
const link = await sfGet(`/api2/repos/${encodeURIComponent(s.repo)}/update-link/?p=${encodeURIComponent(dir)}`, s.token);
|
|
const file = await (await fetch(body.url)).blob();
|
|
const fd = new FormData();
|
|
fd.append('target_file', s.path); fd.append('parent_dir', dir); fd.append('file', file, s.path.split('/').pop());
|
|
const up = await fetch(new URL(link, SEAFILE_URL), { method: 'POST', body: fd });
|
|
if (!up.ok) throw new Error('Seafile save failed ' + up.status);
|
|
}
|
|
if (body.status === 4 || body.status === 2) sessions.delete(id);
|
|
json(res, 200, { error: 0 });
|
|
} catch (e) { console.error('callback:', e.message); json(res, 200, { error: 1 }); }
|
|
}
|
|
|
|
// ---- Plex Music: sign in via plex.tv PIN (no password handled here). Tokens live in HttpOnly cookies, and
|
|
// /plex/* is proxied to the chosen server with the token added server-side, so <audio>/<img> work without exposing it.
|
|
const COOKIE_SECRET = auth.secret;
|
|
const PLEX_HEADERS = {
|
|
Accept: 'application/json', 'X-Plex-Client-Identifier': process.env.PLEX_CLIENT_ID || 'ne-hub-web',
|
|
'X-Plex-Product': 'NE Hub', 'X-Plex-Version': '1.0', 'X-Plex-Device-Name': 'NE Hub', 'X-Plex-Platform': 'Web',
|
|
};
|
|
const mac = v => crypto.createHmac('sha256', COOKIE_SECRET).update(v).digest('base64url');
|
|
const signed = v => v + '.' + mac(v);
|
|
const unsigned = v => { const i = String(v || '').lastIndexOf('.'); return i > 0 && mac(v.slice(0, i)) === v.slice(i + 1) ? v.slice(0, i) : null; };
|
|
const parseCookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
|
|
const setCookies = (req, res, obj, maxAge = 31536000) => {
|
|
const secure = (req.headers['x-forwarded-proto'] || '') === 'https' ? '; Secure' : '';
|
|
res.setHeader('Set-Cookie', Object.entries(obj).map(([k, v]) => `${k}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${v === '' ? 0 : maxAge}${secure}`));
|
|
};
|
|
const plexTv = async (path, token, opts = {}) => {
|
|
const r = await fetch('https://plex.tv' + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
|
|
if (!r.ok) throw new Error('plex.tv ' + r.status);
|
|
return r.json();
|
|
};
|
|
|
|
async function plexApi(req, res) {
|
|
const u = new URL(req.url, 'http://x'), ck = parseCookies(req), token = ck.plex_token;
|
|
try {
|
|
if (u.pathname === '/api/plex/status') {
|
|
return json(res, 200, { signedIn: !!token, server: unsigned(ck.plex_srv) ? ck.plex_name || 'Plex' : null });
|
|
}
|
|
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
|
|
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
|
|
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_HEADERS['X-Plex-Client-Identifier'])}&code=${pin.code}&context%5Bdevice%5D%5Bproduct%5D=NE%20Hub`;
|
|
return json(res, 200, { id: pin.id, code: pin.code, authUrl });
|
|
}
|
|
if (u.pathname === '/api/plex/pin' && req.method === 'GET') {
|
|
const pin = await plexTv(`/api/v2/pins/${encodeURIComponent(u.searchParams.get('id'))}?code=${encodeURIComponent(u.searchParams.get('code'))}`);
|
|
if (!pin.authToken) return json(res, 200, { ok: false });
|
|
setCookies(req, res, { plex_token: pin.authToken });
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
if (!token) return json(res, 401, { error: 'Not signed in to Plex' });
|
|
if (u.pathname === '/api/plex/servers') {
|
|
const rs = await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token);
|
|
return json(res, 200, rs.filter(r => (r.provides || '').includes('server')).map(r => ({ id: r.clientIdentifier, name: r.name, owned: r.owned })));
|
|
}
|
|
if (u.pathname === '/api/plex/server' && req.method === 'POST') {
|
|
const { id, uri: manual } = JSON.parse(await readBody(req));
|
|
const r = (await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token)).find(x => x.clientIdentifier === id);
|
|
if (!r) return json(res, 404, { error: 'Server not found' });
|
|
// Candidates, best first: the server's LAN address over plain http (no DNS, works from a container on the
|
|
// same network), then its plex.direct names, then the public address, then Plex's relay.
|
|
let cands = [];
|
|
if (manual) {
|
|
let m; try { m = new URL(manual); } catch { return json(res, 400, { error: 'That is not a valid address (example: http://192.168.1.10:32400)' }); }
|
|
if (!/^https?:$/.test(m.protocol)) return json(res, 400, { error: 'Address must start with http:// or https://' });
|
|
cands = [{ uri: m.origin, label: 'manual ' + m.host, t: 6000 }];
|
|
} else {
|
|
for (const c of r.connections) {
|
|
if (c.local && c.address && c.port) cands.push({ uri: `http://${c.address}:${c.port}`, label: 'lan ' + c.address, rank: 0, t: 3000 });
|
|
cands.push({ uri: c.uri, label: (c.local ? 'local ' : c.relay ? 'relay ' : 'remote ') + c.address, rank: c.local ? 1 : c.relay ? 3 : 2, t: 6000 });
|
|
}
|
|
cands = cands.sort((a, b) => a.rank - b.rank).filter((c, i, arr) => arr.findIndex(x => x.uri === c.uri) === i);
|
|
}
|
|
const tried = [];
|
|
for (const c of cands) {
|
|
try {
|
|
// /identity needs no token, so the token is never sent to a host before we know it is this server.
|
|
const t = await fetch(c.uri + '/identity', { headers: PLEX_HEADERS, signal: AbortSignal.timeout(c.t) });
|
|
if (!t.ok) { tried.push(`${c.label}: HTTP ${t.status}`); continue; }
|
|
// Plex answers /identity as JSON or XML depending on the server; accept both.
|
|
const body = await t.text();
|
|
let mid; try { mid = JSON.parse(body).MediaContainer?.machineIdentifier; } catch { mid = /machineIdentifier="([^"]+)"/.exec(body)?.[1]; }
|
|
if (!mid) { tried.push(`${c.label}: answered, but not like a Plex server`); continue; }
|
|
if (String(mid).toLowerCase() !== String(id).toLowerCase()) { tried.push(`${c.label}: a different server answered (id ${String(mid).slice(0, 6)}…, wanted ${String(id).slice(0, 6)}…)`); continue; }
|
|
setCookies(req, res, { plex_srv: signed(c.uri), plex_stk: r.accessToken, plex_name: r.name });
|
|
return json(res, 200, { ok: true, name: r.name, via: c.label });
|
|
} catch (e) { tried.push(`${c.label}: ${e.cause?.code || e.name || e.message}`); }
|
|
}
|
|
console.error('Plex connect failed for', r.name, tried);
|
|
// Group identical outcomes so a long list of LAN addresses cannot hide the public/relay results.
|
|
const groups = new Map();
|
|
for (const t of tried) { const i = t.indexOf(': '), label = t.slice(0, i), why = t.slice(i + 2).replace(/\s*\(id [^)]*\)/, '');
|
|
const seen = (t.match(/\(id ([^…]+)…/) || [])[1]; const key = why + (seen ? ` (id ${seen}…)` : '');
|
|
groups.set(key, [...(groups.get(key) || []), label]); }
|
|
const summary = [...groups].map(([why, labels]) => `${why}: ${labels.join(', ')}`).join(' | ');
|
|
return json(res, 502, { error: `Could not reach ${r.name} (id ${String(id).slice(0, 6)}…). ` + summary });
|
|
}
|
|
if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
|
|
setCookies(req, res, { plex_token: '', plex_srv: '', plex_stk: '', plex_name: '' });
|
|
return json(res, 200, { ok: true });
|
|
}
|
|
json(res, 404, { error: 'Not found' });
|
|
} catch (e) { json(res, 502, { error: e.message }); }
|
|
}
|
|
|
|
async function plexProxy(req, res) {
|
|
const ck = parseCookies(req), base = unsigned(ck.plex_srv), tok = ck.plex_stk;
|
|
if (!base || !tok) { res.writeHead(401); return res.end(); }
|
|
const headers = { ...PLEX_HEADERS, 'X-Plex-Token': tok, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity' };
|
|
if (req.headers.range) headers.range = req.headers.range;
|
|
const ac = new AbortController();
|
|
res.on('close', () => ac.abort());
|
|
try {
|
|
const up = await fetch(base + req.url.slice('/plex'.length), { headers, signal: ac.signal });
|
|
const out = {};
|
|
// fetch() transparently decompresses, so an upstream content-length would no longer match the bytes we send.
|
|
const encoded = !!up.headers.get('content-encoding');
|
|
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { if (encoded && h === 'content-length') continue; const v = up.headers.get(h); if (v) out[h] = v; }
|
|
if (req.url.startsWith('/plex/photo/')) out['cache-control'] = 'private, max-age=86400';
|
|
res.writeHead(up.status, out);
|
|
if (up.body) Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res); else res.end();
|
|
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
|
|
}
|
|
|
|
{ // Make it obvious in the log whether data survived a redeploy.
|
|
const dir = process.env.DATA_DIR || require('path').join(__dirname, 'data');
|
|
const count = f => { try { const j = JSON.parse(require('fs').readFileSync(require('path').join(dir, f), 'utf8')); return Array.isArray(j) ? j.length : (j.creds || []).length; } catch { return 0; } };
|
|
console.log(`[data] ${dir}: ${count('passkeys.json')} passkeys, ${count('calendars.json')} calendars`);
|
|
}
|
|
|
|
if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.');
|
|
|
|
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'];
|
|
if (!open.includes(pathname) && !auth.isAuthed(req)) {
|
|
if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); }
|
|
res.writeHead(302, { location: '/login' }); return res.end();
|
|
}
|
|
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, 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);
|
|
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
|
|
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
|
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
|
|
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
|
serveStatic(req, res);
|
|
}).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`));
|