Files
hub/server.js
T
nmemmertandClaude Sonnet 5.5 e22d26bf17
build-container / build (push) Successful in 2m2s
Save settings on the server and sync them to every device
Preferences lived only in the browser, so clearing it, using another device or the separate iOS
home-screen storage lost them. They are now saved in /data/settings.json (the audiobooks sign-in
encrypted), loaded at start-up, and existing browser-only values are uploaded once. A failed save
is reported instead of silent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:59:01 -04:00

315 lines
20 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 tasks = require('./tasks');
const settings = require('./settings');
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
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' : '';
for (const [k, v] of Object.entries(obj)) auth.addCookie(res, `${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(); }
}
// ---- Storage health: is /data a real mounted volume, and what is in it? (so a wiped volume is obvious)
function storageInfo() {
const fs = require('fs'), path = require('path');
const dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
let mounted = null; // null = cannot tell (not Linux)
try { mounted = fs.readFileSync('/proc/self/mountinfo', 'utf8').split('\n').some(l => l.split(' ')[4] === dir); } catch {}
let writable = true; try { const f = path.join(dir, '.write-test'); fs.writeFileSync(f, 'x'); fs.unlinkSync(f); } catch { writable = false; }
const count = (f, pick) => { try { return pick(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); } catch { return 0; } };
return { dir, mounted, writable, passkeys: count('passkeys.json', j => (j.creds || []).length), calendarLinks: count('calendars.json', j => j.length),
events: count('events.json', j => j.length), settings: count('settings.json', j => Object.keys(j).length), icloud: fs.existsSync(path.join(dir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET,
startedAt: STARTED };
}
const STARTED = Date.now();
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted volume: ${i.mounted === null ? 'unknown' : i.mounted ? 'yes' : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
if (i.mounted === false) console.warn('[data] WARNING: the data folder is not a mounted volume. Passkeys, calendars and sessions will be lost whenever the container is recreated. Mount a volume or host folder at /data.'); }
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).
if (auth.isAuthed(req)) auth.refresh(req, res);
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!open.includes(pathname) && !pathname.startsWith('/icons/') && !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 (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res);
if (req.url.startsWith('/api/tasks')) return tasks.handle(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})`));