// Tiny dependency-free server: serves the PWA, exposes config, and proxies Seafile // (Seafile sends no CORS headers, so the browser talks to /sf/* instead). const http = require('http'); const fs = require('fs'); const path = require('path'); const { Readable } = require('stream'); const PORT = process.env.PORT || 8080; const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, ''); const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, ''); const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, ''); const auth = require('./auth'); const cal = require('./cal'); const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin'; const PODCAST_URL = process.env.PODCAST_URL || 'http://172.16.16.106:8383/'; const PUBLIC = path.join(__dirname, 'public'); const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', }; async function proxySeafile(req, res) { const tu = new URL(SEAFILE_URL + req.url.slice('/sf'.length)); const inline = tu.searchParams.has('hubinline'); // preview: show in the page instead of forcing a download tu.searchParams.delete('hubinline'); const target = tu.toString(); const headers = {}; for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range']) { if (req.headers[h]) headers[h] = req.headers[h]; } const hasBody = !['GET', 'HEAD'].includes(req.method); try { const upstream = await fetch(target, { method: req.method, headers, redirect: 'manual', body: hasBody ? Readable.toWeb(req) : undefined, duplex: hasBody ? 'half' : undefined, }); const out = {}; for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges']) { if (inline && h === 'content-disposition') continue; if (h === 'content-length' && upstream.headers.get('content-encoding')) continue; // decompressed by fetch, so the length changed const v = upstream.headers.get(h); if (v) out[h] = v; } // Rewrite redirects so they stay on the proxy and on the Seafile host. const loc = upstream.headers.get('location'); if (loc) { const u = new URL(loc, SEAFILE_URL); if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search; } res.writeHead(upstream.status, out); if (upstream.body) Readable.fromWeb(upstream.body).pipe(res); else res.end(); } catch (e) { res.writeHead(502, { 'content-type': 'application/json' }); const why = e.cause ? (e.cause.code || e.cause.message) : ''; console.error('Seafile proxy:', target.split('?')[0], e.message, why); res.end(JSON.stringify({ error: 'Seafile unreachable: ' + e.message + (why ? ' (' + why + ')' : '') })); } } function serveStatic(req, res) { let p = decodeURIComponent(new URL(req.url, 'http://x').pathname); if (p === '/') p = '/index.html'; if (p === '/login') p = '/login.html'; const file = path.join(PUBLIC, path.normalize(p)); if (!file.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); } fs.readFile(file, (err, data) => { if (err) { res.writeHead(404); return res.end('Not found'); } const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p === '/sw.js') headers['cache-control'] = 'no-cache'; res.writeHead(200, headers); res.end(data); }); } // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const crypto = require('crypto'); const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, ''); const b64u = b => Buffer.from(b).toString('base64url'); const jwtSign = p => { const d = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + '.' + b64u(JSON.stringify(p)); return d + '.' + crypto.createHmac('sha256', JWT_SECRET).update(d).digest('base64url'); }; const jwtVerify = t => { const [h, p, s] = String(t || '').split('.'); if (!s) return null; const want = crypto.createHmac('sha256', JWT_SECRET).update(h + '.' + p).digest(); const got = Buffer.from(s, 'base64url'); if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null; try { return JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; } }; const sessions = new Map(); // callback id -> { token, repo, path, exp }; keeps the user's Seafile token out of URLs setInterval(() => { for (const [k, v] of sessions) if (v.exp < Date.now()) sessions.delete(k); }, 3600e3).unref(); const DOC_TYPE = { word: 'doc docx odt rtf txt', cell: 'xls xlsx ods csv', slide: 'ppt pptx odp' }; const docType = e => Object.keys(DOC_TYPE).find(k => DOC_TYPE[k].split(' ').includes(e)); const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const sfGet = async (p, token) => { const r = await fetch(SEAFILE_URL + p, { headers: { Authorization: 'Token ' + token } }); if (!r.ok) throw new Error('Seafile ' + r.status); return r.json(); }; async function ooConfig(req, res) { const token = (req.headers.authorization || '').replace(/^Token /, ''); try { const { repo, path: p, mobile } = JSON.parse(await readBody(req)); const [url, detail, me] = await Promise.all([ sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(p)}&reuse=1`, token), sfGet(`/api2/repos/${encodeURIComponent(repo)}/file/detail/?p=${encodeURIComponent(p)}`, token), sfGet('/api2/account/info/', token), ]); const name = p.split('/').pop(), fileExt = name.split('.').pop().toLowerCase(); if (!docType(fileExt)) return json(res, 400, { error: 'Unsupported file type' }); const id = crypto.randomUUID(); sessions.set(id, { token, repo, path: p, exp: Date.now() + 12 * 3600e3 }); const origin = PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}`; const config = { document: { fileType: fileExt, title: name, url, key: crypto.createHash('sha1').update(repo + p + detail.mtime).digest('hex').slice(0, 20) }, documentType: docType(fileExt), editorConfig: { mode: 'edit', callbackUrl: `${origin}/api/oo/callback?id=${id}`, user: { id: me.email, name: me.name || me.email }, lang: 'en' }, type: mobile ? 'mobile' : 'desktop', height: '100%', width: '100%', }; if (JWT_SECRET) config.token = jwtSign(config); // unsigned only works when the Document Server has JWT disabled json(res, 200, config); } catch (e) { json(res, 502, { error: e.message }); } } async function ooCallback(req, res) { const id = new URL(req.url, 'http://x').searchParams.get('id'); try { const raw = JSON.parse(await readBody(req)); const auth = (req.headers.authorization || '').replace(/^Bearer /, ''); const body = JWT_SECRET ? jwtVerify(raw.token || auth) : raw; if (!body) return json(res, 403, { error: 1 }); const s = sessions.get(id); // status 2 = ready to save, 6 = force-saved if (s && (body.status === 2 || body.status === 6)) { const dir = s.path.slice(0, s.path.lastIndexOf('/')) || '/'; const link = await sfGet(`/api2/repos/${encodeURIComponent(s.repo)}/update-link/?p=${encodeURIComponent(dir)}`, s.token); const file = await (await fetch(body.url)).blob(); const fd = new FormData(); fd.append('target_file', s.path); fd.append('parent_dir', dir); fd.append('file', file, s.path.split('/').pop()); const up = await fetch(new URL(link, SEAFILE_URL), { method: 'POST', body: fd }); if (!up.ok) throw new Error('Seafile save failed ' + up.status); } if (body.status === 4 || body.status === 2) sessions.delete(id); json(res, 200, { error: 0 }); } catch (e) { console.error('callback:', e.message); json(res, 200, { error: 1 }); } } // ---- Plex Music: sign in via plex.tv PIN (no password handled here). Tokens live in HttpOnly cookies, and // /plex/* is proxied to the chosen server with the token added server-side, so