build-container / build (push) Successful in 2m24s
The saved Plex server token or address can stop working (token rotated, server moved). That looked like "signed out" and sent the person back to the sign-in screen. The proxy now asks plex.tv (using the saved account sign-in) for a fresh token and address, retries once, and saves them. Sign-ins saved before the server id was recorded are matched by server name. A revoked account sign-in signs the person out cleanly; repairs are one at a time and pause 30s after a failure. The server logs [plex] lines for sign-in, connect, repair and failure. tools/plex-test.js (31 checks, fake plex.tv + fake Plex) runs in the Gitea workflow. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
433 lines
27 KiB
JavaScript
433 lines
27 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 search = require('./search');
|
|
const users = require('./users');
|
|
const admin = require('./admin');
|
|
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',
|
|
};
|
|
|
|
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 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 => require('fs').writeFileSync(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';
|
|
|
|
/** 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 });
|
|
}
|
|
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/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 }); }
|
|
}
|
|
|
|
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());
|
|
const attempt = s => fetch(s.srv + req.url.slice('/plex'.length), { signal: ac.signal,
|
|
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';
|
|
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();
|
|
{ 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 only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.');
|
|
|
|
// 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']],
|
|
[/^\/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 })); };
|
|
|
|
async function handleRequest(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 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 ooCallback(req, res);
|
|
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.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 : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET }));
|
|
}
|
|
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(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 (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 (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);
|
|
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));
|