diff --git a/README.md b/README.md index fe064f1..6a4669f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ npm start PORT=9000 npm start # custom port ``` +The UI requires a login. If you do not configure one, the server prints a +generated password at startup for the default `admin` user. For a stable public +deployment, set credentials explicitly: + +```bash +NAUT_AUTH_USER=admin NAUT_AUTH_PASSWORD='change-me' npm start +``` + +You can also set `NAUT_AUTH_PASSWORD_HASH` to a `pbkdf2-sha256$...` hash emitted +by the same password hashing format used by the server. + The server ships 24 mock torrents in varied states and runs a 1-second **simulator** that fluctuates speeds, advances downloads, fills the piece map, drifts the swarm, and accumulates session/ratio stats — so the UI is genuinely @@ -86,11 +97,49 @@ public/ detail.js Detail panel tabs + live refresh + resize api.js fetch wrappers + EventSource live stream format.js bytes/rate/eta/ratio/date/state formatters + plugins.js Frontend plugin registry and extension-point loader + plugins/ + plugins.json Plugin manifest + health.js Example plugin +``` + +### Plugin system +Frontend plugins are ES modules listed in `public/plugins/plugins.json`. Each +module calls `window.Naut.registerPlugin()` and can contribute: + +- `views` — new top-level tabs in the main tab bar. +- `sidebarSections` — new sections in the left sidebar, with optional mount code. +- `detailTabs` — new tabs in the selected torrent detail panel. +- `detailPanels` — additional information panels appended to the General detail tab. + +Minimal plugin: + +```js +window.Naut.registerPlugin({ + id: 'example', + name: 'Example', + views: [{ + id: 'example', + label: 'Example', + render(host, { state, f }) { + host.innerHTML = `
Loaded ${state.snapshot.torrents.length} torrents
`; + }, + }], +}); +``` + +Add the module path to `public/plugins/plugins.json`: + +```json +{ "modules": ["/plugins/example.js"] } ``` ### API (stub) | Method | Path | Purpose | |---|---|---| +| GET | `/api/auth/status` | Session state and login metadata | +| POST | `/api/login`, `/api/logout` | Create or clear a session | +| GET | `/api/plugins` | Authenticated plugin manifest | | GET | `/api/stream` | **SSE** — pushes a compact snapshot (~1/s) | | GET | `/api/snapshot` | One-shot snapshot (grid + server stats) | | GET | `/api/meta` | Categories, tags, tracker hosts, prefs, search plugins | diff --git a/public/css/styles.css b/public/css/styles.css index fa550a0..bea9e79 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -40,6 +40,7 @@ body { overflow: hidden; -webkit-font-smoothing: antialiased; } +[hidden] { display: none !important; } button, input, select { font-family: inherit; font-size: inherit; color: inherit; } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { background: #2a3342; border-radius: 6px; border: 2px solid var(--bg-1); } @@ -51,6 +52,28 @@ button, input, select { font-family: inherit; font-size: inherit; color: inherit height: 100vh; } +/* ---------------- Login ---------------- */ +.login-screen { + position: fixed; inset: 0; display: grid; place-items: center; + background: var(--bg); z-index: 500; +} +.login-box { + width: min(360px, calc(100vw - 32px)); + background: var(--bg-1); + border: 1px solid var(--line); + border-radius: 8px; + padding: 20px; + box-shadow: 0 20px 60px rgba(0,0,0,.45); +} +.login-brand { margin-bottom: 18px; } +.login-submit { width: 100%; padding: 8px 12px; } +.login-error { + color: var(--err); border: 1px solid rgba(240,97,109,.45); + background: rgba(240,97,109,.08); border-radius: 6px; + padding: 8px 10px; margin-bottom: 12px; +} +.login-note { margin: 12px 0 0; font-size: 12px; line-height: 1.4; } + /* ---------------- Toolbar ---------------- */ .toolbar { display: flex; align-items: center; gap: 10px; @@ -283,6 +306,80 @@ table.dtbl td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); whi .kvrow b { color: var(--txt); font-weight: 500; } code.inline { background: var(--bg-3); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; font-family: var(--mono); font-size: 12px; color: var(--accent); } +/* ---------------- Automation view ---------------- */ +.automation-layout { display: flex; gap: 12px; align-items: flex-start; } +.automation-main { flex: 1 1 auto; min-width: 0; } +.automation-settings { flex: 0 0 300px; align-self: stretch; } +/* Below this the side-by-side editor would be too narrow to read (the app also + * spends 232px on the sidebar), so stack — settings ABOVE the script. */ +@media (max-width: 1100px) { + /* Stack vertically, settings above the script. (The base .pane height:100% + * would otherwise make each pane fill the viewport and bury the editor.) */ + .automation-layout { + flex-direction: column; align-items: stretch; + flex: 1 1 auto; min-height: 0; + } + /* Settings shrink to their content, but never take more than half the + * height — scroll internally if there are a lot of them. */ + .automation-settings { + order: -1; align-self: auto; width: 100%; + flex: 0 1 auto; height: auto; max-height: 50%; overflow: auto; + } + /* The script gets the rest (>= half) and its editor fills that space. */ + .automation-main { + width: 100%; flex: 1 1 0; min-height: 0; + display: flex; flex-direction: column; + } + .automation-main .script-editor { flex: 1 1 auto; min-height: 180px; } +} +.settings-form { display: flex; flex-direction: column; gap: 12px; } +.setting-field { display: flex; flex-direction: column; gap: 5px; } +.setting-field.bool { flex-direction: row; align-items: center; gap: 8px; } +.setting-field .setting-label { color: var(--txt-dim); font-size: 12px; } +.setting-field input[type="text"], +.setting-field input[type="number"] { + background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; + padding: 7px 9px; color: var(--txt); font-size: 13px; outline: none; +} +.setting-field input[type="text"]:focus, +.setting-field input[type="number"]:focus { border-color: var(--accent); } +.setting-field.bool input { width: 15px; height: 15px; accent-color: var(--accent); } +.automation-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; } +.automation-head h3 { margin: 0 0 4px; font-size: 15px; } +.script-actions { display: flex; align-items: center; gap: 8px; } +.script-save-status { min-width: 112px; text-align: right; color: var(--txt-faint); font-size: 12px; } +.script-save-status.dirty { color: var(--warn); } +.script-save-status.ok { color: var(--ok); } +.script-save-status.err { color: var(--err); } +.script-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; margin-bottom: 12px; } +.script-stat { background: var(--bg-1); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; } +.script-stat span { display: block; color: var(--txt-dim); font-size: 11px; margin-bottom: 4px; } +.script-stat b { font-size: 18px; font-variant-numeric: tabular-nums; } +.script-error { border: 1px solid var(--err); border-radius: 6px; padding: 8px 10px; margin-bottom: 12px; color: var(--err); } +.script-error code { display: block; margin-top: 4px; color: var(--txt); font-family: var(--mono); font-size: 12px; white-space: pre-wrap; } +.script-editor { + position: relative; min-height: 420px; background: var(--bg-1); + border: 1px solid var(--line); border-radius: 8px; overflow: hidden; +} +.script-editor.disabled { opacity: .75; } +.script-highlight, +.script-editor textarea { + position: absolute; inset: 0; margin: 0; padding: 12px; border: 0; + font: 12px/1.45 var(--mono); tab-size: 4; white-space: pre; +} +.script-highlight { overflow: auto; color: var(--txt); pointer-events: none; } +.script-highlight code { white-space: pre; } +.script-editor textarea { + width: 100%; height: 100%; resize: none; outline: none; overflow: auto; + background: transparent; color: transparent; caret-color: var(--txt); +} +.script-editor textarea::selection { background: rgba(73, 157, 253, .28); color: transparent; } +.tok-comment { color: #697586; } +.tok-string { color: #8bd49c; } +.tok-keyword { color: #7fb4ff; } +.tok-number { color: #d8b4fe; } +.tok-api { color: #f6c177; } + /* ---------------- Search view ---------------- */ .search-bar { display: flex; gap: 8px; margin-bottom: 12px; } .search-bar input { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; } @@ -290,6 +387,7 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad /* ---------------- Settings ---------------- */ .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; } +.plugin-metric { font-size: 28px; line-height: 1; font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; } /* ---------------- Status bar ---------------- */ .statusbar { @@ -312,10 +410,10 @@ code.inline { background: var(--bg-3); border: 1px solid var(--line); border-rad .modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; } .field { margin-bottom: 12px; } .field label { display: block; color: var(--txt-dim); margin-bottom: 4px; font-size: 12px; } -.field input[type=text], .field textarea, .field select { +.field input[type=text], .field input[type=password], .field textarea, .field select { width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px; } -.field input[type=text]:focus, .field textarea:focus, .field select:focus, +.field input[type=text]:focus, .field input[type=password]:focus, .field textarea:focus, .field select:focus, .field input[type=file]:focus { outline: none; border-color: var(--accent); } .field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; } diff --git a/public/index.html b/public/index.html index 07177c3..b59130e 100644 --- a/public/index.html +++ b/public/index.html @@ -50,6 +50,8 @@ +
@@ -60,7 +62,8 @@
diff --git a/public/js/api.js b/public/js/api.js index 67790f4..cb20e01 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -11,10 +11,16 @@ async function jpost(url, body) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); + if (!r.ok) throw new Error(`${url} → ${r.status}`); return r.json(); } export const api = { + authStatus: () => jget('/api/auth/status'), + login: (username, password) => jpost('/api/login', { username, password }), + logout: () => jpost('/api/logout', {}), + plugins: () => jget('/api/plugins'), + meta: () => jget('/api/meta'), snapshot: () => jget('/api/snapshot'), torrent: (hash) => jget(`/api/torrents/${hash}`), @@ -35,6 +41,9 @@ export const api = { rss: () => jget('/api/rss'), rssRules: () => jget('/api/rss/rules'), + script: () => jget('/api/script'), + saveScript: (source) => jpost('/api/script', { source }), + saveScriptSettings: (settings) => jpost('/api/script/settings', { settings }), search: (q) => jget(`/api/search?q=${encodeURIComponent(q)}`), // Live snapshot stream. onSnapshot(snapshot) called ~1/s. diff --git a/public/js/app.js b/public/js/app.js index fdcf295..70b7b6c 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,6 +1,7 @@ // NAUT — main application controller. // Wires the live stream into the sidebar, torrent grid, detail panel, -// status bar, RSS/Search/Engine views, selection, context menu, hotkeys. +// status bar, RSS/Automation/Search/Engine views, selection, context menu, +// hotkeys. import { api } from './api.js'; import * as f from './format.js'; @@ -30,6 +31,8 @@ const state = { searchQuery: '', }; +let viewRenderSeq = 0; + const COLUMNS = { name: { label: 'Name' }, // flexible: absorbs remaining width size: { label: 'Size', num: true, w: '90px' }, @@ -281,12 +284,15 @@ async function refreshMeta() { /* ===================== views ===================== */ function renderView() { + const seq = ++viewRenderSeq; const host = document.getElementById('viewHost'); if (state.view === 'torrents') return renderTorrentsView(host); - if (state.view === 'rss') return renderRssView(host); + if (state.view === 'rss') return renderRssView(host, seq); + if (state.view === 'automation') return renderAutomationView(host, seq); if (state.view === 'search') return renderSearchView(host); if (state.view === 'settings') return renderSettingsView(host); if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext()); + host.innerHTML = '
Unknown view
'; } /* ---------- torrents view ---------- */ @@ -541,12 +547,20 @@ function promptLocation() { if (!hashes.length) return; const cur = state.snapshot.torrents.find((t) => t.hash === hashes[0]); openModal('Set location', ` -
-

Applies to ${hashes.length} torrent(s).

`, +
+
${f.esc(cur?.savePath || '—')}
+
+ +

Moves the torrent's files now. Unchecked, files you + moved individually keep their relative path (or stay put if moved elsewhere). + Applies to ${hashes.length} torrent(s).

`, [{ label: 'Cancel', act: closeModal }, { label: 'Apply', primary: true, act: () => guard(async () => { const savePath = document.getElementById('locPath').value.trim(); - if (savePath) await api.action('setSavePath', hashes, { savePath }); + const reset = document.getElementById('locReset').checked; + if (savePath) await api.action('setSavePath', hashes, { savePath, reset }); closeModal(); }, 'Failed to set location'), }]); @@ -802,9 +816,10 @@ function showContextMenu(x, y, items) { function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); } /* ===================== RSS view ===================== */ -async function renderRssView(host) { +async function renderRssView(host, seq) { host.innerHTML = '
Loading feeds…
'; const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]); + if (seq !== viewRenderSeq || state.view !== 'rss') return; host.innerHTML = `

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 ``; + } + const inputType = s.type === 'number' ? 'number' : 'text'; + return ``; +} + +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 ``; +} + +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'}
+
+
+ + + +
+
+
+ ${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) => ``).join('')} + ${detailTabs().map((t) => ``).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 `

${label}

${value}
`; +} + +function prop(k, v) { + return `
${k}${v}
`; +} + +function table(rows, f) { + return ` + + ${rows.map((t) => ` + + + + + + `).join('')}
NameDoneAvailabilityDownUp
${f.esc(t.name)}${f.pct(t.progress)}${t.availability.toFixed(2)}${f.rate(t.dlspeed)}${f.rate(t.upspeed)}
`; +} 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`); });