Files

664 lines
49 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 atomicWrite = require('./atomic');
const crypto = require('crypto');
const zlib = require('zlib');
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 SEAFILE_TIMEOUT = Math.max(1000, parseInt(process.env.SEAFILE_TIMEOUT_MS, 10) || 30000); // (the tests shorten this)
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 AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || '';
// Back-door sign-in for the audiobooks site. AUDIOBOOKS_LOGIN_LINK is the site's own secret link (the key is part of it); anyone who has it
// is signed in there with full access, so it lives only in the server's environment: never sent to the page, never logged.
// Alternatively AUDIOBOOKS_SSO_SECRET makes short-lived signed links per person.
const AUDIOBOOKS_LOGIN_LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
function audiobooksLogin(user) {
if (AUDIOBOOKS_LOGIN_LINK) return AUDIOBOOKS_LOGIN_LINK;
if (!AUDIOBOOKS_SSO_SECRET) return '';
const body = Buffer.from(JSON.stringify({ u: user.name, id: user.id, exp: Math.floor(Date.now() / 1000) + 120, n: crypto.randomBytes(8).toString('hex') })).toString('base64url');
return AUDIOBOOKS_SSO_URL + '?token=' + body + '.' + crypto.createHmac('sha256', AUDIOBOOKS_SSO_SECRET).update(body).digest('base64url');
}
const auth = require('./auth');
const cal = require('./cal');
const tasks = require('./tasks');
const settings = require('./settings');
const search = require('./search');
const users = require('./users');
const admin = require('./admin');
const backup = require('./backup');
const push = require('./push');
const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID)
const transcripts = require('./transcripts'); // words for podcasts and audiobooks: the show's own transcript, or speech-to-text (TRANSCRIBE_API_URL)
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
const abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_URL)
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
const librivox = require('./librivox'); // free audiobooks from LibriVox
const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes
const podcast = require('./podcast'); // Podgrab podcasts (PODCAST_URL, PODGRAB_PASSWORD)
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
const vault = require('./vault')('nehub-plex');
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',
};
// Seafile calls get 30 s to start answering, file transfers (seafhttp) 2 minutes; a failed read is retried twice; and when the browser goes away
// (tab closed, download cancelled) the upstream request is stopped instead of being left running.
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-range']) {
if (req.headers[h]) headers[h] = req.headers[h];
}
const hasBody = !['GET', 'HEAD'].includes(req.method), canRetry = !hasBody;
const wait = /^\/seafhttp\//.test(tu.pathname) ? 120000 : SEAFILE_TIMEOUT;
let gone = false, ac = null; res.on('close', () => { if (!res.writableFinished) { gone = true; if (ac) ac.abort(); } });
let upstream, lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
if (gone) return;
ac = new AbortController(); let timedOut = false;
const timer = setTimeout(() => { timedOut = true; ac.abort(); }, wait);
try {
upstream = await fetch(target, { method: req.method, headers, redirect: 'manual', signal: ac.signal,
body: hasBody ? Readable.toWeb(req) : undefined, duplex: hasBody ? 'half' : undefined });
clearTimeout(timer);
if (canRetry && [502, 503, 504].includes(upstream.status) && attempt < 2) { try { await upstream.body.cancel(); } catch {} await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); continue; }
break;
} catch (e) {
clearTimeout(timer); upstream = null; lastErr = timedOut ? Object.assign(new Error('Seafile took too long to answer'), { timeout: true }) : e;
if (gone) return;
if (canRetry && attempt < 2) { await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); continue; }
break;
}
}
if (!upstream) {
const e = lastErr, why = e && e.cause ? (e.cause.code || e.cause.message) : '';
console.error('Seafile proxy:', target.split('?')[0], e && e.message, why);
if (res.headersSent) return res.destroy();
res.writeHead(e && e.timeout ? 504 : 502, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: e && e.timeout ? 'Seafile took too long to answer. Try again in a moment.' : 'Seafile unreachable: ' + (e && e.message) + (why ? ' (' + why + ')' : '') }));
}
const out = {};
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges', 'etag', 'last-modified', 'retry-after']) {
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).on('error', () => res.destroy()).pipe(res); else res.end();
}
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'); }
// The pages link to /app.js?v=<build> etc. A new release changes the address, so no proxy cache ("Cache Assets" in
// Nginx Proxy Manager, a CDN, the browser) can hand out an old copy.
if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/[\w-]+\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`));
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change
else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed
headers['cache-control'] = 'no-cache';
headers.etag = `W/"${data.length}-${crypto.createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
if (req.headers['if-none-match'] === headers.etag) { res.writeHead(304, { etag: headers.etag, 'cache-control': 'no-cache' }); return res.end(); }
}
// text files are sent gzipped when the browser accepts it (the app's scripts are several hundred KB), and the compressed copy is kept per version
if (/^(text\/|application\/(json|manifest))|svg/.test(headers['content-type']) && data.length > 1024 && /\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
const key = file + ':' + data.length + ':' + (headers.etag || ''); let z = gzCache.get(key);
if (!z) { z = zlib.gzipSync(data, { level: 6 }); if (gzCache.size > 200) gzCache.clear(); gzCache.set(key, z); }
headers['content-encoding'] = 'gzip'; headers.vary = 'Accept-Encoding'; data = z;
}
res.writeHead(200, headers);
res.end(data);
});
}
const gzCache = new Map();
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
const BUILD = (() => { try { return crypto.createHash('sha1').update(fs.readdirSync(PUBLIC).filter(f => /\.(js|css|html)$/.test(f)).sort().map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
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 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',
};
// Each person's Plex sign-in is stored (encrypted) in their own folder, so it follows them to every device.
const plexFile = () => require('path').join(users.dir(), 'plex.json');
const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } };
const plexSave = o => atomicWrite(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} };
const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this)
const plexTv = async (path, token, opts = {}) => {
const r = await fetch(PLEX_TV + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
if (!r.ok) { const e = new Error('plex.tv ' + r.status); e.status = r.status; throw e; }
return r.json();
};
const RESOURCES = '/api/v2/resources?includeHttps=1&includeRelay=1';
/** plex.tv's Home endpoints answer XML or JSON depending on the call; read either into plain objects. */
const attrs = tag => Object.fromEntries([...tag.matchAll(/([\w:-]+)="([^"]*)"/g)].map(m => [m[1], m[2].replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>')]));
async function plexTvAny(path, token, opts = {}) {
const r = await fetch(PLEX_TV + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
const text = await r.text();
if (!r.ok) { const e = new Error('plex.tv ' + r.status); e.status = r.status; e.body = text; throw e; }
try { return { json: JSON.parse(text), text }; } catch { return { json: null, text }; }
}
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
const plexPinFails = new Map(); // hub person + Plex person -> wrong guesses
async function plexHomeUsers(token) {
const { json, text } = await plexTvAny('/api/home/users', token);
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
return raw.map(u => ({ id: String(u.id), title: u.title || u.username || u.friendlyName || 'User', thumb: u.thumb || '', admin: truthy(u.admin), protected: truthy(u.protected) || truthy(u.hasPassword), restricted: truthy(u.restricted), guest: truthy(u.guest) }));
}
/** Find a working address for one Plex server. Candidates, best first: its 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.
* Returns { ok, uri, label } or { ok: false, summary }. */
async function plexConnect(r, manual) {
const id = r.clientIdentifier;
let cands = [];
if (manual) cands = [{ uri: manual.origin, label: 'manual ' + manual.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; }
return { ok: true, uri: c.uri, label: c.label };
} catch (e) { tried.push(`${c.label}: ${e.cause?.code || e.name || e.message}`); }
}
// 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]); }
return { ok: false, summary: `Could not reach ${r.name} (id ${String(id).slice(0, 6)}…). ` + [...groups].map(([why, labels]) => `${why}: ${labels.join(', ')}`).join(' | ') };
}
// Self-repair: if the saved server token or address stops working, ask plex.tv (using the saved account sign-in) for fresh
// ones and carry on, so a rotated server token never looks like "signed out". One repair at a time per person, and a short
// pause after a failed one so a dead server does not hammer plex.tv.
const healing = new Map(), healFailedAt = new Map();
async function plexHeal() {
const uid = users.current().id;
if (healing.has(uid)) return healing.get(uid);
if (Date.now() - (healFailedAt.get(uid) || 0) < 30e3) return null;
const job = (async () => {
const st = plexLoad();
if (!st.token || (!st.sid && !st.name)) { console.warn('[plex] cannot repair: the saved sign-in does not say which server it was for (choose the server again once)'); return null; }
let rs;
try { rs = await plexTv(RESOURCES, st.token); }
catch (e) {
if (e.status === 401 || e.status === 403) { console.warn('[plex] plex.tv rejected the saved account sign-in (' + e.status + '); signing out so you can sign in again'); plexWipe(); }
else console.warn('[plex] could not reach plex.tv to repair the sign-in:', e.message);
return null;
}
// Sign-ins saved before the server id was recorded are matched by the server's name instead.
const r = rs.find(x => st.sid ? x.clientIdentifier === st.sid : x.name === st.name);
if (!r) { console.warn('[plex] the saved server is no longer on this Plex account'); return null; }
const c = await plexConnect(r);
if (!c.ok) { console.warn('[plex] repair failed: ' + c.summary); return null; }
const fresh = { ...st, srv: c.uri, stk: r.accessToken, name: r.name, sid: r.clientIdentifier };
plexSave(fresh);
console.log(`[plex] repaired the saved server sign-in for ${r.name} (${c.label}${r.accessToken !== st.stk ? ', new server token' : ''})`);
return fresh;
})().then(r => { if (!r) healFailedAt.set(uid, Date.now()); return r; }).finally(() => healing.delete(uid));
healing.set(uid, job);
return job;
}
async function plexApi(req, res) {
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
try {
if (u.pathname === '/api/plex/status') {
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null, canEdit: !!token && await plexCanEdit(st) });
}
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 });
if (token !== pin.authToken) { plexSave({ token: pin.authToken }); console.log('[plex] signed in; the server will be chosen next'); } // a new sign-in; polling again later must not wipe the chosen server
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(RESOURCES, 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(RESOURCES, token)).find(x => x.clientIdentifier === id);
if (!r) return json(res, 404, { error: 'Server not found' });
let m;
if (manual) {
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://' });
}
const c = await plexConnect(r, m);
if (!c.ok) { console.error('[plex] connect failed:', c.summary); return json(res, 502, { error: c.summary }); }
plexSave({ ...st, srv: c.uri, stk: r.accessToken, name: r.name, sid: id }); // sid lets the sign-in repair itself later
console.log(`[plex] connected to ${r.name} (${c.label})`);
return json(res, 200, { ok: true, name: r.name, via: c.label });
}
if (u.pathname === '/api/plex/users' && req.method === 'GET') { // who is in this Plex Home, and who is using NE Hub right now
let users; try { users = await plexHomeUsers(st.mainToken || token); } catch (e) { return json(res, 200, { users: [], current: st.user || null, note: e.status === 404 ? 'This Plex account has no Plex Home.' : 'Could not read the Plex Home: ' + e.message }); }
const cur = st.user ? st.user.id : (users.find(x => x.admin) || {}).id;
return json(res, 200, { users: users.map(x => ({ ...x, current: x.id === cur })), current: st.user || null });
}
if (u.pathname === '/api/plex/switch' && req.method === 'POST') { // use Plex as another person in the Home (their PIN if they have one)
if (!st.sid) return json(res, 400, { error: 'Choose a Plex server first' });
const { id, pin } = JSON.parse(await readBody(req) || '{}');
const owner = st.mainToken || token;
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
const meNow = st.user ? String(st.user.id) : (target.admin && !st.mainToken ? target.id : '');
const same = meNow === target.id; // already using Plex as this person: nothing to prove
// Anyone with a PIN in Plex (the owner included) needs it every time you switch to them; plex.tv checks it, and wrong guesses are slowed down here.
const gate = (users.current() ? users.current().id : '') + ':' + target.id, fails = plexPinFails.get(gate) || { n: 0, until: 0 };
if (target.protected && !same) {
if (fails.until > Date.now()) return json(res, 429, { error: `Too many wrong PINs. Try again in ${Math.ceil((fails.until - Date.now()) / 60000)} min.` });
if (!pin) return json(res, 403, { error: 'That PIN is not right' });
}
let userToken;
if (same) userToken = target.admin && st.mainToken ? st.mainToken : st.token;
else if (target.admin && st.mainToken && !target.protected) userToken = st.mainToken; // back to the account that signed in, which has no PIN
else {
try {
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
} catch (e) {
if (e.status === 401 || e.status === 403) {
if (target.protected) { fails.n++; if (fails.n >= 5) { fails.until = Date.now() + 10 * 60e3; fails.n = 0; } plexPinFails.set(gate, fails); }
return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
}
throw e;
}
if (!userToken && target.admin && st.mainToken) userToken = st.mainToken; // plex.tv accepted the PIN for the owner and gave no new token: the one that signed in is theirs
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
plexPinFails.delete(gate);
}
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
const c = await plexConnect(r);
if (!c.ok) return json(res, 502, { error: c.summary });
plexSave({ ...st, mainToken: st.mainToken || (target.admin ? undefined : token), token: userToken, srv: c.uri, stk: r.accessToken, user: { id: target.id, title: target.title, thumb: target.thumb, admin: target.admin } });
console.log(`[plex] now using Plex as ${target.title}`);
return json(res, 200, { ok: true, user: { id: target.id, title: target.title, thumb: target.thumb, admin: target.admin } });
}
if (u.pathname === '/api/plex/logout' && req.method === 'POST') {
plexWipe();
return json(res, 200, { ok: true });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, 502, { error: e.message }); }
}
// Only playlists and a title's own details (the fields Plex's Edit dialog has, and its poster/background choice) may be changed through the proxy;
// everything else on the Plex server stays read-only. Plex itself refuses edits from anyone who is not allowed, and the hub checks too (plexCanEdit).
const PLEX_PLAYLISTS = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
const EDIT_FIELDS = 'title|titleSort|originalTitle|originallyAvailableAt|contentRating|studio|tagline|summary';
const EDIT_TAGS = 'genre|label|collection';
const EDIT_KEY = new RegExp(`^(type|id|includeExternalMedia|(${EDIT_FIELDS})\\.(value|locked)|(${EDIT_TAGS})\\.locked|(${EDIT_TAGS})\\[\\d{1,3}\\]\\.tag\\.tag)$`);
/** Is this request one of the allowed edits of a title's details? */
function plexEditRequest(method, url) {
const u = new URL(url, 'http://x'), p = u.pathname;
if (u.href.length > 12000) return false;
let m;
if (method === 'PUT' && (m = /^\/plex\/library\/sections\/\d+\/all$/.exec(p))) { // save fields: ?type=1&id=<key>&title.value=..&title.locked=1
const q = u.searchParams;
return /^[1-4]$/.test(q.get('type') || '') && /^\d+$/.test(q.get('id') || '') && [...q.keys()].every(k => EDIT_KEY.test(k));
}
if ((m = /^\/plex\/library\/metadata\/\d+\/(poster|art|posters|arts)$/.exec(p))) { // choose or add a poster / background
const img = u.searchParams.get('url') || '';
return [...u.searchParams.keys()].every(k => k === 'url') && /^(https:\/\/[^\s]{4,2000}|\/library\/metadata\/\d+\/(file|thumb|art)[^\s]*|(upload|metadata|media):\/\/[\w./%-]{1,300})$/.test(img)
&& (m[1].length > 3 ? ['POST', 'PUT'] : ['PUT']).includes(method) && (method === 'PUT' ? /^(poster|art)$/.test(m[1]) : /^(posters|arts)$/.test(m[1]));
}
return false;
}
// Taking something off your own Continue Watching row changes nothing about the library, so anyone signed in may do it.
const PLEX_PERSONAL = /^\/plex\/actions\/removeFromContinueWatching\?ratingKey=\d+$/;
const PLEX_WRITABLE_URL = (method, url) => PLEX_PLAYLISTS.test(url) || (method === 'PUT' && PLEX_PERSONAL.test(url)) || plexEditRequest(method, url);
const editOk = new Map(); // token + server -> { t, ok }
/** Only the server's owner (or an admin of the Home) may edit details: the person's own plex.tv view of the server says so. */
async function plexCanEdit(st) {
if (!st.token || !st.sid) return false;
const k = st.token + '|' + st.sid, c = editOk.get(k);
if (c && Date.now() - c.t < 5 * 60e3) return c.ok;
let ok = false;
try { const rs = await plexTv(RESOURCES, st.token); ok = !!rs.find(x => x.clientIdentifier === st.sid && truthy(x.owned)); } catch { return false; }
editOk.set(k, { t: Date.now(), ok });
return ok;
}
async function plexProxy(req, res) {
const st = plexLoad();
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
const ac = new AbortController();
res.on('close', () => ac.abort());
// A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response.
const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), req.url.startsWith('/plex/video/') ? 45000 : 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } };
const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, method: req.method,
headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } });
let up = null;
try { up = await attempt(st); } catch { up = null; }
if (!up || up.status === 401 || up.status === 403) { // the saved token or address stopped working: repair once, then retry
if (up && up.body) { try { await up.body.cancel(); } catch {} }
const fresh = await plexHeal();
if (fresh) { try { up = await attempt(fresh); } catch { up = null; } }
}
try {
if (!up) { if (!res.headersSent) { res.writeHead(502); res.end(); } return; }
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';
if (/mpegurl/i.test(up.headers.get('content-type') || '') && up.body) { // a playlist: its absolute addresses must stay under /plex so the browser asks the hub, not the site root
const text = (await up.text()).replace(/^\/(?!plex\/)/gm, '/plex/').replace(/URI="\/(?!plex\/)/g, 'URI="/plex/');
delete out['content-length']; res.writeHead(up.status, out); return res.end(text);
}
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)
/** What is mounted at the data folder? Docker's own anonymous volumes look "mounted" but are lost when the container is recreated. */
function classifyMount(mountinfoLine) {
const root = (mountinfoLine || '').split(' ')[3] || '';
const vol = /\/volumes\/([^/]+)\/_data/.exec(root);
if (vol) return /^[0-9a-f]{64}$/.test(vol[1]) ? { kind: 'anonymous', name: vol[1].slice(0, 12) } : { kind: 'named', name: vol[1] };
return { kind: 'folder', name: root };
}
function storageInfo() {
const fs = require('fs'), path = require('path');
const dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
let mounted = null, source = null; // null = cannot tell (not Linux)
try { const l = fs.readFileSync('/proc/self/mountinfo', 'utf8').split('\n').find(x => x.split(' ')[4] === dir); mounted = !!l; if (l) source = classifyMount(l); } catch {}
let writable = true; try { const f = path.join(dir, '.write-test'); fs.writeFileSync(f, 'x'); fs.unlinkSync(f); } catch { writable = false; }
const count = (root, f, pick) => { try { return pick(JSON.parse(fs.readFileSync(path.join(root, f), 'utf8'))); } catch { return 0; } };
const udir = (() => { try { return users.dir(); } catch { const a = users.firstAdmin(); return a ? users.dirOf(a.id) : dir; } })(); // the viewer's own folder
return { dir, mounted, source, writable, people: users.all().length, passkeys: count(dir, 'passkeys.json', j => (j.creds || []).length), calendarLinks: count(udir, 'calendars.json', j => j.length),
events: count(udir, 'events.json', j => j.length), settings: count(udir, 'settings.json', j => Object.keys(j).length), icloud: fs.existsSync(path.join(udir, 'icloud.json')), secretFromEnv: !!process.env.COOKIE_SECRET,
startedAt: STARTED };
}
const STARTED = Date.now();
push.start();
transcripts.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.TRANSCRIBE_API_URL || '', model: process.env.TRANSCRIBE_MODEL || '', port: PORT });
transcripts.startAuto({ on: process.env.TRANSCRIBE_AUTO !== '0', keep: process.env.TRANSCRIBE_KEEP }); // new and existing episodes are transcribed in the background (TRANSCRIBE_AUTO=0 turns it off; TRANSCRIBE_KEEP = how many of each show's newest)
lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' });
signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' });
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
if (i.source && i.source.kind === 'anonymous') console.warn('[data] WARNING: /data is an anonymous Docker volume, not your host folder. It is deleted when the container is recreated. Check that your stack mounts the host folder at /data.');
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.'); }
// ---- DuckDuckGo instant answers for the Search tab (a short summary when DDG has one; many queries return nothing)
const ddgCache = new Map();
async function ddgAnswer(req, res) {
const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().slice(0, 200);
if (q.length < 2) return json(res, 200, {});
const hit = ddgCache.get(q.toLowerCase());
if (hit && Date.now() - hit.t < 10 * 60e3) return json(res, 200, hit.v);
try {
const u = new URL('https://api.duckduckgo.com/');
u.search = new URLSearchParams({ q, format: 'json', no_html: '1', skip_disambig: '1', t: 'nehub' });
const r = await fetch(u, { signal: AbortSignal.timeout(6000), headers: { Accept: 'application/json' } });
const d = await r.json();
const v = {};
if (d.AbstractText) Object.assign(v, { heading: d.Heading, text: d.AbstractText.slice(0, 600), source: d.AbstractSource, url: d.AbstractURL, image: d.Image ? 'https://duckduckgo.com' + d.Image : '' });
else if (d.Definition) Object.assign(v, { heading: d.Heading || q, text: d.Definition.slice(0, 600), source: d.DefinitionSource, url: d.DefinitionURL });
if (d.Answer && typeof d.Answer === 'string') v.answer = d.Answer.slice(0, 200);
if (ddgCache.size > 200) ddgCache.clear();
ddgCache.set(q.toLowerCase(), { t: Date.now(), v });
json(res, 200, v);
} catch { json(res, 200, {}); } // a failed lookup must never break Search
}
if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing is switched off (the save callback would be unauthenticated). Set it, or ALLOW_UNSIGNED_OO=1 for testing only.');
const OO_OK = !!JWT_SECRET || process.env.ALLOW_UNSIGNED_OO === '1';
// Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all).
const ACCESS = [
[/^\/sf\//, ['files', 'office']],
[/^\/api\/oo\/config$/, ['office', 'files']],
[/^\/(api\/plex\/|plex\/)/, ['music', 'video']],
[/^\/api\/(audiobooks|ab)\//, ['audio']],
[/^\/api\/podcast\//, ['podcast']],
[/^\/api\/vbv\//, ['podcast']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
[/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music']],
[/^\/api\/transcript$/, ['podcast', 'audio']],
[/^\/api\/mail\//, ['mail']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']],
];
const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); };
// Headers for every response. No script/style rules on purpose (the app uses inline handlers); these stop framing by other sites,
// content sniffing, form hijacking and leaking the address to other sites.
function securityHeaders(res) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()');
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
}
/** Cross-site request check: a browser request that changes something must come from this same site. */
function crossSite(req) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return false;
const sfs = req.headers['sec-fetch-site'];
if (sfs) return !['same-origin', 'none'].includes(sfs);
const o = req.headers.origin; if (!o) return false; // not a browser form/fetch (scripts, tests, OnlyOffice)
try { const h = new URL(o).host; return ![req.headers['x-forwarded-host'], req.headers.host, PUBLIC_URL && new URL(PUBLIC_URL).host].includes(h); } catch { return true; }
}
async function handleRequest(req, res) {
const pathname = new URL(req.url, 'http://x').pathname;
securityHeaders(res);
if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); }
if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused');
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
if (pathname === '/api/mail/callback' && mail.outlookOn()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are
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 user = auth.currentUser(req);
if (user) auth.refresh(req, res, user);
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!user) {
if (open.includes(pathname) || pathname.startsWith('/icons/')) {
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return OO_OK ? ooCallback(req, res) : json(res, 403, { error: 1 });
return serveStatic(req, res);
}
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();
}
// From here on, everything runs "as" this person: their own folder, and only the tabs they were given.
return users.runAs(user, async () => {
const rule = ACCESS.find(([re]) => re.test(pathname));
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
if (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
if (pathname === '/api/health') { // everything an admin wants to know at a glance
const fs = require('fs'), dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), out = { at: Date.now() };
try { const f = fs.statfsSync(dir); out.disk = { free: f.bavail * f.bsize, total: f.blocks * f.bsize }; } catch { out.disk = null; }
const bk = require('./audit').recent(2000).find(x => x.event === 'backup downloaded'); out.backup = bk ? { t: bk.t, who: bk.who } : null;
out.uptime = Math.round((Date.now() - STARTED) / 1000); out.node = process.version;
const ps = plexLoad();
out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })();
try { out.mail = await mail.health(); } catch { out.mail = []; }
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
}
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 (pathname === '/api/search') return search.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 (pathname === '/api/audiobooks/enter') { // the secret sign-in link is added here, on the server, and never appears in the app's page or script
const to = audiobooksLogin(user);
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
}
if (pathname === '/api/transcript') return transcripts.handle(req, res);
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
if (pathname.startsWith('/api/ab/free/')) return librivox.handle(req, res);
if (pathname.startsWith('/api/ab/')) return abapi.handle(req, res);
if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res);
if (pathname.startsWith('/api/read/')) return books.handle(req, res);
if (pathname.startsWith('/api/radio/')) return radio.handle(req, res);
if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res);
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || (req.method === 'PUT' && PLEX_PERSONAL.test(req.url)) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
return serveStatic(req, res);
});
}
// A bug or bad input in one request must never take the whole server down: log it and answer that request with an error.
http.createServer(async (req, res) => {
try { await handleRequest(req, res); }
catch (e) {
console.error('[error]', req.method, req.url.split('?')[0], e && e.stack || e);
if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Server error: ' + (e && e.message || 'unknown') })); } else res.destroy();
}
}).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`));
process.on('unhandledRejection', e => console.error('[unhandledRejection]', e && e.stack || e));
process.on('uncaughtException', e => console.error('[uncaughtException]', e && e.stack || e));