Feeds
@@ -842,6 +857,213 @@ function renderRule(r) {
`;
}
+/* ===================== Automation view ===================== */
+function highlightLua(source) {
+ const tokenRe = /--\[\[[\s\S]*?\]\]|\[\[[\s\S]*?\]\]|--[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b|\b\d+(?:\.\d+)?\b|\b(?:naut|event)\b/g;
+ let out = '';
+ let last = 0;
+ let m;
+ while ((m = tokenRe.exec(source)) !== null) {
+ const token = m[0];
+ out += f.esc(source.slice(last, m.index));
+ let cls = 'tok-keyword';
+ if (token.startsWith('--')) cls = 'tok-comment';
+ else if (token[0] === '"' || token[0] === "'" || token.startsWith('[[')) cls = 'tok-string';
+ else if (/^\d/.test(token)) cls = 'tok-number';
+ else if (token === 'naut' || token === 'event') cls = 'tok-api';
+ out += `
${f.esc(token)} `;
+ last = tokenRe.lastIndex;
+ }
+ out += f.esc(source.slice(last));
+ return out;
+}
+
+function syncScriptHighlight(editor, highlight) {
+ highlight.innerHTML = `${highlightLua(editor.value)}\n`;
+ highlight.parentElement.scrollTop = editor.scrollTop;
+ highlight.parentElement.scrollLeft = editor.scrollLeft;
+}
+
+// One form control per declared setting, keyed for collection on save.
+function settingField(s) {
+ const key = f.esc(s.key);
+ const label = f.esc(s.label || s.key);
+ const val = s.value ?? s.default ?? '';
+ if (s.type === 'bool') {
+ const on = String(val) === 'true' || String(val) === '1';
+ return `
+
+ ${label}
+ `;
+ }
+ const inputType = s.type === 'number' ? 'number' : 'text';
+ return `
+ ${label}
+
+ `;
+}
+
+function settingsPanel(settings) {
+ const list = Array.isArray(settings) ? settings : [];
+ const body = list.length
+ ? list.map(settingField).join('')
+ : `
This script exposes no settings.
+ Call naut.define_settings{…} in the script to add configurable
+ variables here.
`;
+ return `
+
+
+
Settings
+
Configure the script without editing it
+
+
+
+ Save
+
+
+ ${body}
+ `;
+}
+
+async function renderAutomationView(host, seq) {
+ host.innerHTML = '
Loading automation script…
';
+ let script;
+ try {
+ script = await api.script();
+ } catch (e) {
+ if (seq !== viewRenderSeq || state.view !== 'automation') return;
+ host.innerHTML = '
Unable to load script status
';
+ return;
+ }
+ if (seq !== viewRenderSeq || state.view !== 'automation') return;
+ const stat = (label, value) => `
${label} ${value ?? 0}
`;
+ const source = script.source || '';
+ host.innerHTML = `
+
+
+
+
Automation Script
+
${script.loaded ? f.esc(script.path || 'loaded') : 'No script loaded'}
+
+
+
+ Save
+ Revert
+
+
+
+ ${stat('Queued', script.queued)}
+ ${stat('Handled', script.handled)}
+ ${stat('Dropped', script.dropped)}
+ ${stat('Errors', script.errors)}
+ ${stat('Move requests', script.move_requests)}
+
+ ${script.last_error ? `
Last error ${f.esc(script.last_error)}
` : ''}
+
+
+ ${settingsPanel(script.settings)}
+
`;
+ bindSettingsPanel(script);
+ const editor = document.getElementById('scriptEditor');
+ const highlight = document.getElementById('scriptHighlight');
+ const saveBtn = document.getElementById('saveScript');
+ const refreshBtn = document.getElementById('refreshScript');
+ const status = document.getElementById('scriptSaveStatus');
+ let initialSource = script.loaded ? source : editor.value;
+ let saving = false;
+ const setStatus = (message, kind = '') => {
+ status.textContent = message;
+ status.className = `script-save-status ${kind}`;
+ };
+ const updateDirty = (preserveStatus = false) => {
+ const dirty = editor.value !== initialSource;
+ saveBtn.disabled = !script.loaded || !dirty || saving;
+ if (!saving && !preserveStatus) setStatus(dirty ? 'Unsaved changes' : '', dirty ? 'dirty' : '');
+ };
+ syncScriptHighlight(editor, highlight);
+ editor.addEventListener('input', () => {
+ syncScriptHighlight(editor, highlight);
+ updateDirty();
+ });
+ editor.addEventListener('scroll', () => syncScriptHighlight(editor, highlight));
+ editor.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ editor.setRangeText(' ', editor.selectionStart, editor.selectionEnd, 'end');
+ editor.dispatchEvent(new Event('input'));
+ } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ if (!saveBtn.disabled) saveBtn.click();
+ }
+ });
+ saveBtn.addEventListener('click', async () => {
+ saving = true;
+ saveBtn.disabled = true;
+ setStatus('Saving…');
+ try {
+ const updated = await api.saveScript(editor.value);
+ initialSource = updated.source || editor.value;
+ editor.value = initialSource;
+ syncScriptHighlight(editor, highlight);
+ script = updated;
+ saving = false;
+ updateDirty(true);
+ setStatus('Saved', 'ok');
+ toast('Script saved', 'ok');
+ } catch (e) {
+ console.error(e);
+ saving = false;
+ updateDirty(true);
+ setStatus('Save failed', 'err');
+ toast('Script save failed', 'err');
+ }
+ });
+ refreshBtn.addEventListener('click', () => renderAutomationView(host, viewRenderSeq));
+ updateDirty();
+}
+
+// Wire the script-settings form: collect values by key and save them, without
+// touching the script source.
+function bindSettingsPanel(script) {
+ const form = document.getElementById('settingsForm');
+ const saveBtn = document.getElementById('saveSettings');
+ const status = document.getElementById('settingsSaveStatus');
+ if (!form || !saveBtn) return;
+ const setStatus = (msg, kind = '') => {
+ status.textContent = msg;
+ status.className = `script-save-status ${kind}`;
+ };
+ const collect = () => {
+ const out = {};
+ form.querySelectorAll('[data-skey]').forEach((el) => {
+ out[el.dataset.skey] = el.dataset.stype === 'bool'
+ ? (el.checked ? 'true' : 'false')
+ : el.value;
+ });
+ return out;
+ };
+ form.addEventListener('input', () => setStatus('Unsaved changes', 'dirty'));
+ saveBtn.addEventListener('click', async () => {
+ saveBtn.disabled = true;
+ setStatus('Saving…');
+ try {
+ await api.saveScriptSettings(collect());
+ setStatus('Saved', 'ok');
+ toast('Settings saved', 'ok');
+ } catch (e) {
+ console.error(e);
+ setStatus('Save failed', 'err');
+ toast('Settings save failed', 'err');
+ } finally {
+ saveBtn.disabled = false;
+ }
+ });
+}
+
/* ===================== Search view ===================== */
function renderSearchView(host) {
const plugins = state.meta.searchPlugins.map((p) =>
@@ -997,8 +1219,10 @@ function bindGlobal() {
doAction(a);
}));
- document.querySelectorAll('.vtab').forEach((b) =>
- b.addEventListener('click', () => switchViewTab(b.dataset.view)));
+ document.getElementById('viewtabs').addEventListener('click', (e) => {
+ const tab = e.target.closest('.vtab[data-view]');
+ if (tab) switchViewTab(tab.dataset.view);
+ });
const qf = document.getElementById('quickFilter');
qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); });
diff --git a/public/js/detail.js b/public/js/detail.js
index 0334947..8a401cb 100644
--- a/public/js/detail.js
+++ b/public/js/detail.js
@@ -2,8 +2,9 @@
import { api } from './api.js';
import * as f from './format.js';
+import { pluginDetailTabs, renderPluginDetailPanels, renderPluginDetailTab } from './plugins.js';
-const TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
+const BUILTIN_TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
let activeTab = 'general';
let currentHash = null;
let refreshTimer = null;
@@ -22,7 +23,7 @@ export function renderDetailShell(hash, host) {
- ${TABS.map((t) => `${tabLabel(t)} `).join('')}
+ ${detailTabs().map((t) => `${t.label} `).join('')}
✕
@@ -39,6 +40,13 @@ function tabLabel(t) {
return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t];
}
+function detailTabs() {
+ return [
+ ...BUILTIN_TABS.map((id) => ({ id, label: tabLabel(id) })),
+ ...pluginDetailTabs().map((tab) => ({ id: tab.id, label: tab.label })),
+ ];
+}
+
export function closeDetail() {
currentHash = null;
if (refreshTimer) clearInterval(refreshTimer);
@@ -58,6 +66,7 @@ async function loadTab() {
else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
else if (activeTab === 'content') { body.innerHTML = renderFiles(await api.files(currentHash)); bindFilePriorities(body); }
else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
+ else await renderPluginDetailTab(activeTab, body, { api, f, hash: currentHash });
} catch { /* ignore transient */ }
};
await render();
@@ -107,6 +116,7 @@ function renderGeneral(t) {
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
${row('Force start', t.forceStart ? 'On' : 'Off')}
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
+ ${renderPluginDetailPanels(t, { api, f, hash: t.hash })}
`;
}
diff --git a/public/js/plugins.js b/public/js/plugins.js
new file mode 100644
index 0000000..8c24295
--- /dev/null
+++ b/public/js/plugins.js
@@ -0,0 +1,99 @@
+// Frontend plugin registry.
+// Plugin modules call window.Naut.registerPlugin({...}) after they are imported.
+
+const registry = {
+ plugins: [],
+ views: [],
+ sidebarSections: [],
+ detailTabs: [],
+ detailPanels: [],
+};
+
+let sharedContext = {};
+
+function normalizeList(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function registerPlugin(plugin) {
+ if (!plugin || !plugin.id || registry.plugins.some((p) => p.id === plugin.id)) return;
+ registry.plugins.push(plugin);
+ for (const view of normalizeList(plugin.views)) registry.views.push({ plugin, ...view });
+ for (const section of normalizeList(plugin.sidebarSections)) registry.sidebarSections.push({ plugin, ...section });
+ for (const tab of normalizeList(plugin.detailTabs)) registry.detailTabs.push({ plugin, ...tab });
+ for (const panel of normalizeList(plugin.detailPanels)) registry.detailPanels.push({ plugin, ...panel });
+}
+
+window.Naut = {
+ ...(window.Naut || {}),
+ registerPlugin,
+ plugins: registry,
+ context: () => sharedContext,
+};
+
+export async function loadPlugins(api, context) {
+ sharedContext = context || {};
+ let manifest;
+ try { manifest = await api.plugins(); } catch { manifest = { modules: [] }; }
+ for (const modulePath of manifest.modules || []) {
+ try { await import(modulePath); }
+ catch (e) { console.error(`Failed to load plugin ${modulePath}`, e); }
+ }
+ return registry.plugins;
+}
+
+export function pluginViews() {
+ return registry.views.filter((v) => v.id && v.label && typeof v.render === 'function');
+}
+
+export function getPluginView(id) {
+ return pluginViews().find((v) => v.id === id);
+}
+
+export function renderPluginView(id, host, context) {
+ const view = getPluginView(id);
+ if (!view) return false;
+ view.render(host, context);
+ return true;
+}
+
+export function renderPluginSidebarSections(context) {
+ return registry.sidebarSections
+ .filter((s) => s.id && s.title && typeof s.render === 'function')
+ .map((s) => `
+
${context.f.esc(s.title)}
${s.render(context) || ''}
+
`)
+ .join('');
+}
+
+export function mountPluginSidebarSections(root, context) {
+ for (const section of registry.sidebarSections) {
+ const el = root.querySelector(`[data-plugin-sidebar="${CSS.escape(`${section.plugin.id}:${section.id}`)}"]`);
+ if (el && typeof section.onMount === 'function') section.onMount(el, context);
+ }
+}
+
+export function pluginDetailTabs() {
+ return registry.detailTabs.filter((t) => t.id && t.label && typeof t.render === 'function');
+}
+
+export async function renderPluginDetailTab(id, host, context) {
+ const tab = pluginDetailTabs().find((t) => t.id === id);
+ if (!tab) return false;
+ await tab.render(host, context);
+ return true;
+}
+
+export function renderPluginDetailPanels(torrent, context) {
+ return registry.detailPanels
+ .filter((p) => p.title && typeof p.render === 'function')
+ .map((p) => {
+ try {
+ return `
${context.f.esc(p.title)}
${p.render(torrent, context) || ''}`;
+ } catch (e) {
+ console.error(`Failed to render plugin panel ${p.plugin.id}:${p.id || p.title}`, e);
+ return '';
+ }
+ })
+ .join('');
+}
diff --git a/public/plugins/health.js b/public/plugins/health.js
new file mode 100644
index 0000000..cc70b72
--- /dev/null
+++ b/public/plugins/health.js
@@ -0,0 +1,95 @@
+window.Naut.registerPlugin({
+ id: 'health',
+ name: 'Health Overview',
+
+ views: [{
+ id: 'health',
+ label: 'Health',
+ render(host, { state, f }) {
+ const torrents = state.snapshot.torrents;
+ const lowAvailability = torrents.filter((t) => t.availability < 1.05);
+ const idleComplete = torrents.filter((t) => t.progress >= 1 && t.upspeed === 0);
+ const slowDownloads = torrents.filter((t) => t.progress < 1 && t.dlspeed < 32 * 1024);
+ const rows = [...lowAvailability, ...slowDownloads]
+ .filter((t, i, arr) => arr.findIndex((x) => x.hash === t.hash) === i)
+ .sort((a, b) => a.availability - b.availability)
+ .slice(0, 18);
+
+ host.innerHTML = `
+
+ ${metric('Low availability', lowAvailability.length)}
+ ${metric('Idle completed', idleComplete.length)}
+ ${metric('Slow downloads', slowDownloads.length)}
+
+
+
Torrents needing attention
+ ${rows.length ? table(rows, f) : '
No health issues in the current snapshot
'}
+
+
`;
+ },
+ }],
+
+ sidebarSections: [{
+ id: 'health',
+ title: 'Health',
+ render({ state }) {
+ const torrents = state.snapshot.torrents;
+ const attention = torrents.filter((t) => t.availability < 1.05 || (t.progress < 1 && t.dlspeed < 32 * 1024)).length;
+ return `
+ + Needs attention ${attention}
+
`;
+ },
+ onMount(root, { setView }) {
+ root.querySelector('[data-health-open]')?.addEventListener('click', () => setView('health'));
+ },
+ }],
+
+ detailTabs: [{
+ id: 'health-detail',
+ label: 'Health',
+ async render(host, { api, hash, f }) {
+ const t = await api.torrent(hash);
+ host.innerHTML = `
+
Health
+ ${prop('Availability', t.availability.toFixed(3))}
+ ${prop('Connected seeds', `${t.seeds} / ${t.seedsTotal}`)}
+ ${prop('Connected peers', `${t.peers} / ${t.peersTotal}`)}
+ ${prop('Last activity', f.ago(t.lastActivity))}
+ ${prop('Transfer state', t.dlspeed || t.upspeed ? 'active' : 'idle')}
+
`;
+ },
+ }],
+
+ detailPanels: [{
+ id: 'health-summary',
+ title: 'Plugin: Health',
+ render(t, { f }) {
+ const flags = [];
+ if (t.availability < 1.05) flags.push('low availability');
+ if (t.progress < 1 && t.dlspeed < 32 * 1024) flags.push('slow download');
+ if (t.progress >= 1 && t.upspeed === 0) flags.push('idle seeding');
+ return `${prop('Attention', flags.length ? flags.join(', ') : 'none')}
+ ${prop('Last activity', f.ago(t.lastActivity))}`;
+ },
+ }],
+});
+
+function metric(label, value) {
+ return `
`;
+}
+
+function prop(k, v) {
+ return `
${k} ${v}
`;
+}
+
+function table(rows, f) {
+ return `
+ Name Done Availability Down Up
+ ${rows.map((t) => `
+ ${f.esc(t.name)}
+ ${f.pct(t.progress)}
+ ${t.availability.toFixed(2)}
+ ${f.rate(t.dlspeed)}
+ ${f.rate(t.upspeed)}
+ `).join('')}
`;
+}
diff --git a/public/plugins/plugins.json b/public/plugins/plugins.json
new file mode 100644
index 0000000..1f7502e
--- /dev/null
+++ b/public/plugins/plugins.json
@@ -0,0 +1,5 @@
+{
+ "modules": [
+ "/plugins/health.js"
+ ]
+}
diff --git a/server/data.js b/server/data.js
index 80bb25f..7d90211 100644
--- a/server/data.js
+++ b/server/data.js
@@ -430,6 +430,20 @@ export const db = {
rssRules,
searchPlugins,
runSearch,
+ script: {
+ loaded: true,
+ path: 'examples/anime_sort.lua',
+ source: `function on_file_complete(event)
+ naut.move_file(event.torrent_id, event.index, event.path .. ".sorted")
+end
+`,
+ queued: 12,
+ handled: 12,
+ dropped: 0,
+ errors: 0,
+ move_requests: 3,
+ last_error: '',
+ },
// server preferences (subset, advanced)
preferences: {
dl_limit: 0,
diff --git a/server/index.js b/server/index.js
index 9c5b753..44be81f 100644
--- a/server/index.js
+++ b/server/index.js
@@ -3,6 +3,7 @@
// 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';
@@ -13,6 +14,16 @@ 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',
@@ -99,6 +110,67 @@ function findMany(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; } },
@@ -126,6 +198,36 @@ 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, {
@@ -169,6 +271,21 @@ async function handleApi(req, res, url) {
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);
@@ -338,5 +455,10 @@ const server = http.createServer(async (req, res) => {
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`);
});