Files
hub/server.js
T
nmemmertandClaude Sonnet 5.5 a4907e0cee
build-container / build (push) Successful in 2m25s
Show the underlying cause when Seafile is unreachable
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:49:10 -04:00

260 lines
15 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 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;
const v = upstream.headers.get(h);
if (v) out[h] = v;
}
// Rewrite redirects so they stay on the proxy and on the Seafile host.
const loc = upstream.headers.get('location');
if (loc) {
const u = new URL(loc, SEAFILE_URL);
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
}
res.writeHead(upstream.status, out);
if (upstream.body) Readable.fromWeb(upstream.body).pipe(res); else res.end();
} catch (e) {
res.writeHead(502, { 'content-type': 'application/json' });
const why = e.cause ? (e.cause.code || e.cause.message) : '';
console.error('Seafile proxy:', target.split('?')[0], e.message, why);
res.end(JSON.stringify({ error: 'Seafile unreachable: ' + e.message + (why ? ' (' + why + ')' : '') }));
}
}
function serveStatic(req, res) {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (p === '/') p = '/index.html';
if (p === '/login') p = '/login.html';
const file = path.join(PUBLIC, path.normalize(p));
if (!file.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); return res.end('Not found'); }
const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' };
if (p === '/sw.js') headers['cache-control'] = 'no-cache';
res.writeHead(200, headers);
res.end(data);
});
}
// ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile)
const crypto = require('crypto');
const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
const b64u = b => Buffer.from(b).toString('base64url');
const jwtSign = p => {
const d = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + '.' + b64u(JSON.stringify(p));
return d + '.' + crypto.createHmac('sha256', JWT_SECRET).update(d).digest('base64url');
};
const jwtVerify = t => {
const [h, p, s] = String(t || '').split('.');
if (!s) return null;
const want = crypto.createHmac('sha256', JWT_SECRET).update(h + '.' + p).digest();
const got = Buffer.from(s, 'base64url');
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
try { return JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; }
};
const sessions = new Map(); // callback id -> { token, repo, path, exp }; keeps the user's Seafile token out of URLs
setInterval(() => { for (const [k, v] of sessions) if (v.exp < Date.now()) sessions.delete(k); }, 3600e3).unref();
const DOC_TYPE = { word: 'doc docx odt rtf txt', cell: 'xls xlsx ods csv', slide: 'ppt pptx odp' };
const docType = e => Object.keys(DOC_TYPE).find(k => DOC_TYPE[k].split(' ').includes(e));
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const sfGet = async (p, token) => {
const r = await fetch(SEAFILE_URL + p, { headers: { Authorization: 'Token ' + token } });
if (!r.ok) throw new Error('Seafile ' + r.status);
return r.json();
};
async function ooConfig(req, res) {
const token = (req.headers.authorization || '').replace(/^Token /, '');
try {
const { repo, path: p, mobile } = JSON.parse(await readBody(req));
const [url, detail, me] = await Promise.all([
sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(p)}&reuse=1`, token),
sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/detail/?p=${encodeURIComponent(p)}`, token),
sfGet('/api2/account/info/', token),
]);
const name = p.split('/').pop(), fileExt = name.split('.').pop().toLowerCase();
if (!docType(fileExt)) return json(res, 400, { error: 'Unsupported file type' });
const id = crypto.randomUUID();
sessions.set(id, { token, repo, path: p, exp: Date.now() + 12 * 3600e3 });
const origin = PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}`;
const config = {
document: { fileType: fileExt, title: name, url, key: crypto.createHash('sha1').update(repo + p + detail.mtime).digest('hex').slice(0, 20) },
documentType: docType(fileExt),
editorConfig: { mode: 'edit', callbackUrl: `${origin}/api/oo/callback?id=${id}`, user: { id: me.email, name: me.name || me.email }, lang: 'en' },
type: mobile ? 'mobile' : 'desktop', height: '100%', width: '100%',
};
if (JWT_SECRET) config.token = jwtSign(config); // unsigned only works when the Document Server has JWT disabled
json(res, 200, config);
} catch (e) { json(res, 502, { error: e.message }); }
}
async function ooCallback(req, res) {
const id = new URL(req.url, 'http://x').searchParams.get('id');
try {
const raw = JSON.parse(await readBody(req));
const auth = (req.headers.authorization || '').replace(/^Bearer /, '');
const body = JWT_SECRET ? jwtVerify(raw.token || auth) : raw;
if (!body) return json(res, 403, { error: 1 });
const s = sessions.get(id);
// status 2 = ready to save, 6 = force-saved
if (s && (body.status === 2 || body.status === 6)) {
const dir = s.path.slice(0, s.path.lastIndexOf('/')) || '/';
const link = await sfGet(`/api2/repos/${encodeURIComponent(s.repo)}/update-link/?p=${encodeURIComponent(dir)}`, s.token);
const file = await (await fetch(body.url)).blob();
const fd = new FormData();
fd.append('target_file', s.path); fd.append('parent_dir', dir); fd.append('file', file, s.path.split('/').pop());
const up = await fetch(new URL(link, SEAFILE_URL), { method: 'POST', body: fd });
if (!up.ok) throw new Error('Seafile save failed ' + up.status);
}
if (body.status === 4 || body.status === 2) sessions.delete(id);
json(res, 200, { error: 0 });
} catch (e) { console.error('callback:', e.message); json(res, 200, { error: 1 }); }
}
// ---- Plex Music: sign in via plex.tv PIN (no password handled here). Tokens live in HttpOnly cookies, and
// /plex/* is proxied to the chosen server with the token added server-side, so <audio>/<img> work without exposing it.
const COOKIE_SECRET = auth.secret;
const PLEX_HEADERS = {
Accept: 'application/json', 'X-Plex-Client-Identifier': process.env.PLEX_CLIENT_ID || 'ne-hub-web',
'X-Plex-Product': 'NE Hub', 'X-Plex-Version': '1.0', 'X-Plex-Device-Name': 'NE Hub', 'X-Plex-Platform': 'Web',
};
const mac = v => crypto.createHmac('sha256', COOKIE_SECRET).update(v).digest('base64url');
const signed = v => v + '.' + mac(v);
const unsigned = v => { const i = String(v || '').lastIndexOf('.'); return i > 0 && mac(v.slice(0, i)) === v.slice(i + 1) ? v.slice(0, i) : null; };
const parseCookies = req => Object.fromEntries((req.headers.cookie || '').split(/; */).filter(Boolean).map(c => { const i = c.indexOf('='); return [c.slice(0, i), decodeURIComponent(c.slice(i + 1))]; }));
const setCookies = (req, res, obj, maxAge = 31536000) => {
const secure = (req.headers['x-forwarded-proto'] || '') === 'https' ? '; Secure' : '';
res.setHeader('Set-Cookie', Object.entries(obj).map(([k, v]) => `${k}=${encodeURIComponent(v)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${v === '' ? 0 : maxAge}${secure}`));
};
const plexTv = async (path, token, opts = {}) => {
const r = await fetch('https://plex.tv' + path, { ...opts, headers: { ...PLEX_HEADERS, ...(token ? { 'X-Plex-Token': token } : {}) } });
if (!r.ok) throw new Error('plex.tv ' + r.status);
return r.json();
};
async function plexApi(req, res) {
const u = new URL(req.url, 'http://x'), ck = parseCookies(req), token = ck.plex_token;
try {
if (u.pathname === '/api/plex/status') {
return json(res, 200, { signedIn: !!token, server: unsigned(ck.plex_srv) ? ck.plex_name || 'Plex' : null });
}
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_HEADERS['X-Plex-Client-Identifier'])}&code=${pin.code}&context%5Bdevice%5D%5Bproduct%5D=NE%20Hub`;
return json(res, 200, { id: pin.id, code: pin.code, authUrl });
}
if (u.pathname === '/api/plex/pin' && req.method === 'GET') {
const pin = await plexTv(`/api/v2/pins/${encodeURIComponent(u.searchParams.get('id'))}?code=${encodeURIComponent(u.searchParams.get('code'))}`);
if (!pin.authToken) return json(res, 200, { ok: false });
setCookies(req, res, { plex_token: pin.authToken });
return json(res, 200, { ok: true });
}
if (!token) return json(res, 401, { error: 'Not signed in to Plex' });
if (u.pathname === '/api/plex/servers') {
const rs = await plexTv('/api/v2/resources?includeHttps=1&includeRelay=1', token);
return json(res, 200, rs.filter(r => (r.provides || '').includes('server')).map(r => ({ id: r.clientIdentifier, name: r.name, owned: r.owned })));
}
if (u.pathname === '/api/plex/server' && req.method === 'POST') {
const { id } = 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' });
// Prefer direct remote connections, then relay, then local; first one that answers wins.
const order = c => (c.local ? 2 : c.relay ? 1 : 0);
for (const c of [...r.connections].sort((a, b) => order(a) - order(b))) {
try {
const t = await fetch(c.uri + '/identity', { headers: { ...PLEX_HEADERS, 'X-Plex-Token': r.accessToken }, signal: AbortSignal.timeout(4000) });
if (!t.ok) 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 });
} catch {}
}
return json(res, 502, { error: 'Could not reach this server. Check that Remote Access is enabled in Plex.' });
}
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' };
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 = {};
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { 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(); }
}
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);
// Everything else needs a passkey session, except the sign-in page and its assets,
// and OnlyOffice's save callback (which carries its own signed token).
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!open.includes(pathname) && !auth.isAuthed(req)) {
if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); }
res.writeHead(302, { location: '/login' }); return res.end();
}
if (req.url === '/api/config') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: SEAFILE_URL, onlyoffice: ONLYOFFICE_URL, audiobooks: AUDIOBOOKS_URL, editor: true, jwt: !!JWT_SECRET }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
serveStatic(req, res);
}).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`));