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:
ookami125 2026-06-21 23:22:00 -04:00
parent 5a5b5f4330
commit 9a1d67acd4
11 changed files with 741 additions and 13 deletions

View file

@ -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,

View file

@ -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`);
});