webui: automation settings panel, set-location, responsive layout
- Automation tab: settings form (define_settings) beside the editor, stacking above it when narrow; script/settings save split. - Set location modal: current location + reset checkbox. - saveScriptSettings/setSavePath API wiring; plugins panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5a5b5f4330
commit
9a1d67acd4
11 changed files with 741 additions and 13 deletions
49
README.md
49
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 = `<div class="pane">Loaded ${state.snapshot.torrents.length} torrents</div>`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
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 |
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@
|
|||
</div>
|
||||
<button class="btn alt-toggle" data-act="altspeed" title="Toggle alternative speed limits">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M4 18a8 8 0 0 1 16 0M12 18l3.5-5"/><circle cx="12" cy="18" r="1.3" fill="currentColor" stroke="none"/></svg> Alt</button>
|
||||
<button class="btn icon" data-act="logout" title="Sign out">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M10 6H6.5a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 6.5 18H10"/><path d="M14 8l4 4-4 4M18 12H9"/></svg></button>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
|
|
@ -60,7 +62,8 @@
|
|||
<section class="content">
|
||||
<nav class="viewtabs" id="viewtabs">
|
||||
<button class="vtab active" data-view="torrents">Torrents</button>
|
||||
<button class="vtab" data-view="rss">RSS & Automation</button>
|
||||
<button class="vtab" data-view="rss">RSS</button>
|
||||
<button class="vtab" data-view="automation">Automation</button>
|
||||
<button class="vtab" data-view="search">Search</button>
|
||||
<button class="vtab" data-view="settings">Engine</button>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
240
public/js/app.js
240
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 = '<div class="pane"><div class="empty">Unknown view</div></div>';
|
||||
}
|
||||
|
||||
/* ---------- 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', `
|
||||
<div class="field"><label>Save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
|
||||
<p class="dim" style="font-size:12px">Applies to ${hashes.length} torrent(s).</p>`,
|
||||
<div class="field"><label>Current location</label>
|
||||
<div class="dim" style="font-family:var(--mono);font-size:12px;word-break:break-all">${f.esc(cur?.savePath || '—')}</div></div>
|
||||
<div class="field"><label>New save path</label><input type="text" id="locPath" value="${f.esc(cur?.savePath || '')}" /></div>
|
||||
<label class="field" style="flex-direction:row;align-items:center;gap:8px">
|
||||
<input type="checkbox" id="locReset" />
|
||||
<span>Reset files to their original paths</span></label>
|
||||
<p class="dim" style="font-size:12px">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).</p>`,
|
||||
[{ 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 = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
||||
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
|
||||
if (seq !== viewRenderSeq || state.view !== 'rss') return;
|
||||
host.innerHTML = `<div class="pane"><div class="split2">
|
||||
<div>
|
||||
<div class="card"><h3>Feeds</h3>
|
||||
|
|
@ -842,6 +857,213 @@ function renderRule(r) {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
/* ===================== 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 += `<span class="${cls}">${f.esc(token)}</span>`;
|
||||
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 class="setting-field bool">
|
||||
<input type="checkbox" data-skey="${key}" data-stype="bool" ${on ? 'checked' : ''}>
|
||||
<span class="setting-label">${label}</span>
|
||||
</label>`;
|
||||
}
|
||||
const inputType = s.type === 'number' ? 'number' : 'text';
|
||||
return `<label class="setting-field">
|
||||
<span class="setting-label">${label}</span>
|
||||
<input type="${inputType}" data-skey="${key}" data-stype="${f.esc(s.type || 'string')}"
|
||||
value="${f.esc(String(val))}" spellcheck="false">
|
||||
</label>`;
|
||||
}
|
||||
|
||||
function settingsPanel(settings) {
|
||||
const list = Array.isArray(settings) ? settings : [];
|
||||
const body = list.length
|
||||
? list.map(settingField).join('')
|
||||
: `<div class="dim" style="padding:4px 0">This script exposes no settings.
|
||||
Call <code>naut.define_settings{…}</code> in the script to add configurable
|
||||
variables here.</div>`;
|
||||
return `<aside class="pane automation-settings">
|
||||
<div class="automation-head">
|
||||
<div>
|
||||
<h3>Settings</h3>
|
||||
<div class="dim">Configure the script without editing it</div>
|
||||
</div>
|
||||
<div class="script-actions">
|
||||
<span class="script-save-status" id="settingsSaveStatus"></span>
|
||||
<button class="btn primary" id="saveSettings" ${list.length ? '' : 'disabled'}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-form" id="settingsForm">${body}</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
async function renderAutomationView(host, seq) {
|
||||
host.innerHTML = '<div class="pane"><div class="empty">Loading automation script…</div></div>';
|
||||
let script;
|
||||
try {
|
||||
script = await api.script();
|
||||
} catch (e) {
|
||||
if (seq !== viewRenderSeq || state.view !== 'automation') return;
|
||||
host.innerHTML = '<div class="pane"><div class="empty">Unable to load script status</div></div>';
|
||||
return;
|
||||
}
|
||||
if (seq !== viewRenderSeq || state.view !== 'automation') return;
|
||||
const stat = (label, value) => `<div class="script-stat"><span>${label}</span><b>${value ?? 0}</b></div>`;
|
||||
const source = script.source || '';
|
||||
host.innerHTML = `<div class="automation-layout">
|
||||
<div class="pane automation-main">
|
||||
<div class="automation-head">
|
||||
<div>
|
||||
<h3>Automation Script</h3>
|
||||
<div class="dim">${script.loaded ? f.esc(script.path || 'loaded') : 'No script loaded'}</div>
|
||||
</div>
|
||||
<div class="script-actions">
|
||||
<span class="script-save-status" id="scriptSaveStatus"></span>
|
||||
<button class="btn primary" id="saveScript" ${script.loaded ? '' : 'disabled'}>Save</button>
|
||||
<button class="btn" id="refreshScript">Revert</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="script-stats">
|
||||
${stat('Queued', script.queued)}
|
||||
${stat('Handled', script.handled)}
|
||||
${stat('Dropped', script.dropped)}
|
||||
${stat('Errors', script.errors)}
|
||||
${stat('Move requests', script.move_requests)}
|
||||
</div>
|
||||
${script.last_error ? `<div class="script-error"><b>Last error</b><code>${f.esc(script.last_error)}</code></div>` : ''}
|
||||
<div class="script-editor ${script.loaded ? '' : 'disabled'}">
|
||||
<pre class="script-highlight" aria-hidden="true"><code id="scriptHighlight"></code></pre>
|
||||
<textarea id="scriptEditor" spellcheck="false" ${script.loaded ? '' : 'disabled'}>${f.esc(script.loaded ? source : '-- no script loaded')}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
${settingsPanel(script.settings)}
|
||||
</div>`;
|
||||
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(); });
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div class="detail" id="detailPanel">
|
||||
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
|
||||
<div class="detail-tabs">
|
||||
${TABS.map((t) => `<button class="dtab ${t === activeTab ? 'active' : ''}" data-dtab="${t}">${tabLabel(t)}</button>`).join('')}
|
||||
${detailTabs().map((t) => `<button class="dtab ${t.id === activeTab ? 'active' : ''}" data-dtab="${t.id}">${t.label}</button>`).join('')}
|
||||
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)">✕</button>
|
||||
</div>
|
||||
<div class="detail-body" id="detailBody"></div>
|
||||
|
|
@ -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 })}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
99
public/js/plugins.js
Normal file
99
public/js/plugins.js
Normal file
|
|
@ -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) => `<div class="side-group plugin-side" data-plugin-sidebar="${s.plugin.id}:${s.id}">
|
||||
<div class="side-head">${context.f.esc(s.title)}</div>${s.render(context) || ''}
|
||||
</div>`)
|
||||
.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 `<div class="section-h">${context.f.esc(p.title)}</div>${p.render(torrent, context) || ''}`;
|
||||
} catch (e) {
|
||||
console.error(`Failed to render plugin panel ${p.plugin.id}:${p.id || p.title}`, e);
|
||||
return '';
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
95
public/plugins/health.js
Normal file
95
public/plugins/health.js
Normal file
|
|
@ -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 = `<div class="pane">
|
||||
<div class="settings-grid">
|
||||
${metric('Low availability', lowAvailability.length)}
|
||||
${metric('Idle completed', idleComplete.length)}
|
||||
${metric('Slow downloads', slowDownloads.length)}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Torrents needing attention</h3>
|
||||
${rows.length ? table(rows, f) : '<div class="empty" style="height:120px">No health issues in the current snapshot</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
}],
|
||||
|
||||
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 `<div class="side-item" data-health-open>
|
||||
<span class="ic">+</span><span class="lbl">Needs attention</span><span class="cnt">${attention}</span>
|
||||
</div>`;
|
||||
},
|
||||
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 = `<div class="props">
|
||||
<div class="section-h">Health</div>
|
||||
${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')}
|
||||
</div>`;
|
||||
},
|
||||
}],
|
||||
|
||||
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 `<div class="card"><h3>${label}</h3><div class="plugin-metric">${value}</div></div>`;
|
||||
}
|
||||
|
||||
function prop(k, v) {
|
||||
return `<div class="prop"><span class="k">${k}</span><span class="v">${v}</span></div>`;
|
||||
}
|
||||
|
||||
function table(rows, f) {
|
||||
return `<table class="dtbl"><thead><tr>
|
||||
<th>Name</th><th class="num">Done</th><th class="num">Availability</th><th class="num">Down</th><th class="num">Up</th>
|
||||
</tr></thead><tbody>${rows.map((t) => `<tr>
|
||||
<td>${f.esc(t.name)}</td>
|
||||
<td class="num">${f.pct(t.progress)}</td>
|
||||
<td class="num">${t.availability.toFixed(2)}</td>
|
||||
<td class="num">${f.rate(t.dlspeed)}</td>
|
||||
<td class="num">${f.rate(t.upspeed)}</td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
5
public/plugins/plugins.json
Normal file
5
public/plugins/plugins.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"modules": [
|
||||
"/plugins/health.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,
|
||||
|
|
|
|||
122
server/index.js
122
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`);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue