// Zero-dependency stub server for the torrent web UI. // Built-in `http` only: serves the static frontend, a JSON API, and a // Server-Sent Events stream that pushes a live snapshot every second. import http from 'node:http'; import { pbkdf2Sync, randomBytes, timingSafeEqual } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; import { db } from './data.js'; import { tick, globalStats } from './simulator.js'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PUBLIC_DIR = join(__dirname, '..', 'public'); const PORT = process.env.PORT || 8088; const AUTH_USER = process.env.NAUT_AUTH_USER || process.env.NAUT_USER || 'admin'; const SESSION_COOKIE = 'naut_session'; const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; const SESSION_SECRET = process.env.NAUT_SESSION_SECRET || randomBytes(32).toString('base64url'); const generatedPassword = !process.env.NAUT_AUTH_PASSWORD && !process.env.NAUT_PASSWORD && !process.env.NAUT_AUTH_PASSWORD_HASH ? randomBytes(12).toString('base64url') : ''; const AUTH_PASSWORD = process.env.NAUT_AUTH_PASSWORD || process.env.NAUT_PASSWORD || generatedPassword; const AUTH_PASSWORD_HASH = process.env.NAUT_AUTH_PASSWORD_HASH || hashPassword(AUTH_PASSWORD); const sessions = new Map(); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', }; // ---- run the simulator on a fixed cadence ---- setInterval(tick, 1000); // ---- SSE clients ---- const sseClients = new Set(); function broadcast() { if (sseClients.size === 0) return; const payload = JSON.stringify(snapshot()); const frame = `event: snapshot\ndata: ${payload}\n\n`; for (const res of sseClients) res.write(frame); } setInterval(broadcast, 1000); // A compact snapshot for the live grid (omits heavy per-torrent detail). function snapshot() { return { ts: Date.now(), server: globalStats(), torrents: db.torrents.map((t) => ({ hash: t.hash, name: t.name, size: t.size, progress: t.progress, dlspeed: t.dlspeed, upspeed: t.upspeed, state: t.state, eta: t.eta, seeds: t.seeds, seedsTotal: t.seedsTotal, peers: t.peers, peersTotal: t.peersTotal, ratio: t.ratio, category: t.category, tags: t.tags, savePath: t.savePath, addedOn: t.addedOn, completionOn: t.completionOn, lastActivity: t.lastActivity, downloaded: t.downloaded, uploaded: t.uploaded, availability: t.availability, priority: t.priority, seqDl: t.seqDl, superSeeding: t.superSeeding, forceStart: t.forceStart, timeActive: t.timeActive, private: t.private, trackerHosts: trackerHostsOf(t), })), }; } function hostOf(url) { try { return new URL(url).host; } catch { return url; } } function trackerHostsOf(t) { return t.trackers.filter((tr) => tr.tier >= 0).map((tr) => hostOf(tr.url)); } function json(res, data, code = 200) { const body = JSON.stringify(data); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(body); } async function readBody(req) { const chunks = []; for await (const c of req) chunks.push(c); if (!chunks.length) return {}; try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { return {}; } } function byHash(hash) { return db.torrents.find((t) => t.hash === hash); } function findMany(hashes) { const set = new Set(hashes); return db.torrents.filter((t) => set.has(t.hash)); } function hashPassword(password, salt = randomBytes(16).toString('base64url')) { const iterations = 210000; const digest = pbkdf2Sync(String(password), salt, iterations, 32, 'sha256').toString('base64url'); return `pbkdf2-sha256$${iterations}$${salt}$${digest}`; } function verifyPassword(password, stored) { const [scheme, iterRaw, salt, digest] = String(stored || '').split('$'); if (scheme !== 'pbkdf2-sha256' || !iterRaw || !salt || !digest) return false; const actual = pbkdf2Sync(String(password), salt, Number(iterRaw), 32, 'sha256'); const expected = Buffer.from(digest, 'base64url'); return actual.length === expected.length && timingSafeEqual(actual, expected); } function parseCookies(req) { const out = {}; for (const part of (req.headers.cookie || '').split(';')) { const idx = part.indexOf('='); if (idx > -1) out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()); } return out; } function cookie(res, value, req) { const secure = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https'; res.setHeader('Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${secure ? '; Secure' : ''}`); } function clearCookie(res) { res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } function currentUser(req) { const token = parseCookies(req)[SESSION_COOKIE]; if (!token) return null; const sess = sessions.get(token); if (!sess || sess.expires < Date.now()) { if (sess) sessions.delete(token); return null; } sess.expires = Date.now() + SESSION_TTL_MS; return sess.user; } function createSession(res, req) { const token = `${randomBytes(24).toString('base64url')}.${randomBytes(8).toString('base64url')}.${SESSION_SECRET.slice(0, 8)}`; sessions.set(token, { user: AUTH_USER, expires: Date.now() + SESSION_TTL_MS }); cookie(res, token, req); } async function readPluginManifest() { try { const raw = await readFile(join(PUBLIC_DIR, 'plugins', 'plugins.json'), 'utf8'); const manifest = JSON.parse(raw); const modules = Array.isArray(manifest.modules) ? manifest.modules : []; return { modules: modules.filter((m) => typeof m === 'string' && m.startsWith('/plugins/') && m.endsWith('.js')) }; } catch { return { modules: [] }; } } // ---- action handlers (mutate the stub state) ---- const ACTIONS = { pause: (t) => { if (!t.state.startsWith('paused')) { t.state = t.progress >= 1 ? 'pausedUP' : 'pausedDL'; t.dlspeed = 0; t.upspeed = 0; t.forceStart = false; } }, resume: (t) => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; }, forceStart: (t) => { t.forceStart = true; t.state = t.progress >= 1 ? 'forcedUP' : 'forcedDL'; }, recheck: (t) => { t.state = t.progress >= 1 ? 'checkingUP' : 'checkingDL'; setTimeout(() => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; }, 3000); }, reannounce: (t) => { t.lastActivity = Date.now(); }, toggleSeqDl: (t) => { t.seqDl = !t.seqDl; }, toggleSuperSeeding: (t) => { t.superSeeding = !t.superSeeding; }, setCategory: (t, p) => { t.category = p.category ?? ''; }, setSavePath: (t, p) => { if (p.savePath) { t.savePath = p.savePath; t.contentPath = `${p.savePath}/${t.name}`; } }, addTags: (t, p) => { for (const tag of p.tags || []) if (!t.tags.includes(tag)) t.tags.push(tag); }, removeTags: (t, p) => { t.tags = t.tags.filter((x) => !(p.tags || []).includes(x)); }, setDownLimit: (t, p) => { t.downLimit = p.limit ?? 0; }, setUpLimit: (t, p) => { t.upLimit = p.limit ?? 0; }, setRatioLimit: (t, p) => { t.ratioLimit = p.limit ?? -1; }, setFilePriority: (t, p) => { if (t.files[p.index]) t.files[p.index].priority = p.priority; }, topPriority: (t) => { t.priority = 1; }, bottomPriority: (t) => { t.priority = 99; }, increasePriority: (t) => { t.priority = Math.max(1, (t.priority || 1) - 1); }, decreasePriority: (t) => { t.priority = (t.priority || 1) + 1; }, }; async function handleApi(req, res, url) { const parts = url.pathname.split('/').filter(Boolean); // ['api', ...] const seg = parts.slice(1); // drop 'api' // Auth endpoints stay outside the protected API surface. if (seg[0] === 'auth' && seg[1] === 'status' && req.method === 'GET') { return json(res, { authenticated: !!currentUser(req), user: AUTH_USER, generatedPassword: !!generatedPassword, }); } if (seg[0] === 'login' && req.method === 'POST') { const body = await readBody(req); if ((body.username || '') !== AUTH_USER || !verifyPassword(body.password || '', AUTH_PASSWORD_HASH)) { return json(res, { ok: false, error: 'invalid credentials' }, 401); } createSession(res, req); return json(res, { ok: true, user: AUTH_USER }); } if (seg[0] === 'logout' && req.method === 'POST') { const token = parseCookies(req)[SESSION_COOKIE]; if (token) sessions.delete(token); clearCookie(res); return json(res, { ok: true }); } if (!currentUser(req)) return json(res, { error: 'authentication required' }, 401); // GET /api/plugins (frontend plugin manifest) if (seg[0] === 'plugins' && req.method === 'GET') return json(res, await readPluginManifest()); // GET /api/stream (SSE) if (seg[0] === 'stream') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); res.write('retry: 2000\n\n'); res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot())}\n\n`); sseClients.add(res); req.on('close', () => sseClients.delete(res)); return; } // GET /api/snapshot if (seg[0] === 'snapshot' && req.method === 'GET') return json(res, snapshot()); // GET /api/meta (sidebar data) if (seg[0] === 'meta' && req.method === 'GET') { return json(res, { categories: db.categories, tags: db.tags, trackers: trackerSummary(), preferences: db.preferences, searchPlugins: db.searchPlugins, }); } // GET /api/preferences / POST to update if (seg[0] === 'preferences') { if (req.method === 'GET') return json(res, db.preferences); if (req.method === 'POST') { Object.assign(db.preferences, await readBody(req)); return json(res, db.preferences); } } // POST /api/altspeed (toggle alt speed) if (seg[0] === 'altspeed' && req.method === 'POST') { db.preferences.alt_speed_enabled = !db.preferences.alt_speed_enabled; return json(res, { alt_speed_enabled: db.preferences.alt_speed_enabled }); } if (seg[0] === 'script') { if (req.method === 'GET') return json(res, db.script); if (req.method === 'POST') { const body = await readBody(req); if (typeof body.source !== 'string') { return json(res, { error: 'missing source' }, 400); } db.script.loaded = true; db.script.source = body.source; db.script.handled += 1; db.script.last_error = ''; return json(res, db.script); } } // Categories: create (POST /api/categories) / delete (POST /api/categories/delete) if (seg[0] === 'categories' && req.method === 'POST') { const body = await readBody(req); if (seg[1] === 'delete') { const name = (body.name || '').trim(); if (name) { db.categories = db.categories.filter((c) => c.name !== name); for (const t of db.torrents) if (t.category === name) t.category = ''; } return json(res, db.categories); } const name = (body.name || '').trim(); if (name && !db.categories.some((c) => c.name === name)) { db.categories.push({ name, savePath: (body.savePath || `${db.preferences.save_path}/${name}`).trim() }); } else if (name && body.savePath) { db.categories.find((c) => c.name === name).savePath = body.savePath.trim(); // edit existing } return json(res, db.categories); } // Tags: create (POST /api/tags) / delete (POST /api/tags/delete) if (seg[0] === 'tags' && req.method === 'POST') { const body = await readBody(req); if (seg[1] === 'delete') { const name = (body.name || '').trim(); if (name) { db.tags = db.tags.filter((x) => x !== name); for (const t of db.torrents) t.tags = t.tags.filter((x) => x !== name); } return json(res, db.tags); } const name = (body.name || '').trim(); if (name && !db.tags.includes(name)) db.tags.push(name); return json(res, db.tags); } // /api/torrents ... if (seg[0] === 'torrents') { // GET /api/torrents/:hash/:tab if (req.method === 'GET' && seg[1]) { const t = byHash(seg[1]); if (!t) return json(res, { error: 'not found' }, 404); const tab = seg[2]; if (tab === 'trackers') return json(res, t.trackers); if (tab === 'peers') return json(res, t.peersList); if (tab === 'files') return json(res, t.files); if (tab === 'pieces') return json(res, { pieceSize: t.pieceSize, pieceCount: t.pieceCount, pieces: t.pieces }); // default: full general properties return json(res, t); } // GET /api/torrents -> full list (rarely needed; stream is primary) if (req.method === 'GET') return json(res, snapshot().torrents); } // POST /api/action { action, hashes:[], params:{} } if (seg[0] === 'action' && req.method === 'POST') { const { action, hashes, params } = await readBody(req); const fn = ACTIONS[action]; if (!fn) return json(res, { error: `unknown action: ${action}` }, 400); const targets = findMany(hashes || []); for (const t of targets) fn(t, params || {}); return json(res, { ok: true, affected: targets.length }); } // DELETE via POST /api/delete { hashes:[], deleteFiles:bool } if (seg[0] === 'delete' && req.method === 'POST') { const { hashes } = await readBody(req); const set = new Set(hashes || []); const before = db.torrents.length; db.torrents = db.torrents.filter((t) => !set.has(t.hash)); return json(res, { ok: true, removed: before - db.torrents.length }); } // POST /api/add { magnet | name+size+pieceCount+pieceSize+files, category, savePath, paused, skipCheck, seqDl } if (seg[0] === 'add' && req.method === 'POST') { const p = await readBody(req); const name = (p.magnet && decodeURIComponent((p.magnet.match(/dn=([^&]+)/) || [])[1] || '')) || p.name || 'new-torrent.iso'; const GiB = 1024 * 1024 * 1024; const size = p.size || GiB; const pieceSize = p.pieceSize || 1024 * 1024; const pieceCount = Math.min(p.pieceCount || Math.max(1, Math.ceil(size / pieceSize)), 4000); const files = (Array.isArray(p.files) && p.files.length) ? p.files.map((fl) => ({ name: fl.name, size: fl.size, progress: 0, priority: 1, availability: 1 })) : [{ name, size, progress: 0, priority: 1, availability: 1 }]; const savePath = p.savePath || db.preferences.save_path; const hash = Math.random().toString(16).slice(2).padEnd(40, '0').slice(0, 40); db.torrents.unshift({ hash, name, size, progress: 0, dlspeed: p.paused ? 0 : 256 * 1024, upspeed: 0, state: p.paused ? 'pausedDL' : (p.magnet ? 'metaDL' : 'downloading'), eta: 8640000, seeds: 4, seedsTotal: 40, peers: 2, peersTotal: 20, ratio: 0, ratioLimit: -1, category: p.category || '', tags: [], savePath, contentPath: `${savePath}/${name}`, addedOn: Date.now(), completionOn: -1, lastActivity: Date.now(), seenComplete: -1, downloaded: 0, uploaded: 0, downloadedSession: 0, uploadedSession: 0, availability: 1, priority: 1, seqDl: !!p.seqDl, superSeeding: false, autoTMM: true, forceStart: false, pieceSize, pieceCount, pieces: new Array(pieceCount).fill(0), downLimit: 0, upLimit: 0, timeActive: 0, comment: '', createdBy: 'uploaded .torrent', creationDate: Date.now(), private: false, magnetUri: p.magnet || '', files, trackers: [ { url: '** [DHT] **', tier: -1, status: 'working', seeds: 4, peers: 2, leeches: -1, downloaded: -1, message: '' }, { url: 'udp://tracker.opentrackr.org:1337/announce', tier: 0, status: 'working', seeds: 10, peers: 4, leeches: 4, downloaded: 0, message: '' }, ], peersList: [], }); return json(res, { ok: true, hash }); } // RSS if (seg[0] === 'rss') { if (seg[1] === 'rules') return json(res, db.rssRules); return json(res, db.rssFeeds); } // Search: GET /api/search?q=... if (seg[0] === 'search' && req.method === 'GET') { return json(res, db.runSearch(url.searchParams.get('q') || '')); } return json(res, { error: 'not found' }, 404); } function trackerSummary() { const map = new Map(); for (const t of db.torrents) { for (const tr of t.trackers) { if (tr.tier === -1) continue; // skip DHT/PeX/LSD pseudo let host; try { host = new URL(tr.url).host; } catch { host = tr.url; } map.set(host, (map.get(host) || 0) + 1); } } return [...map.entries()].map(([host, count]) => ({ host, count })).sort((a, b) => b.count - a.count); } async function serveStatic(req, res, url) { let pathname = decodeURIComponent(url.pathname); if (pathname === '/') pathname = '/index.html'; const filePath = normalize(join(PUBLIC_DIR, pathname)); if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403).end('forbidden'); return; } try { const data = await readFile(filePath); const type = MIME[extname(filePath)] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' }); res.end(data); } catch { // SPA fallback try { const data = await readFile(join(PUBLIC_DIR, 'index.html')); res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.end(data); } catch { res.writeHead(404).end('not found'); } } } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); try { if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url); return await serveStatic(req, res, url); } catch (err) { console.error(err); json(res, { error: 'internal error', detail: String(err) }, 500); } }); server.listen(PORT, () => { console.log(`\n torrent-ui stub server running`); console.log(` → http://localhost:${PORT}\n`); console.log(` auth user: ${AUTH_USER}`); if (generatedPassword) { console.log(` generated password: ${generatedPassword}`); console.log(` set NAUT_AUTH_PASSWORD or NAUT_AUTH_PASSWORD_HASH for a stable public deployment\n`); } console.log(` ${db.torrents.length} mock torrents, live simulator ticking every 1s\n`); });