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