add: upload the actual .torrent bytes

readTorrentFile only returned parsed metadata for the preview, so a
file-based add sent no torrent content and the server rejected it. Include
the raw bytes as base64 ("data") alongside the preview fields so uploads
actually add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-17 00:43:26 -04:00
parent 0b35571565
commit 5a5b5f4330

View file

@ -5,6 +5,14 @@
import { api } from './api.js'; import { api } from './api.js';
import * as f from './format.js'; import * as f from './format.js';
import { renderDetailShell, closeDetail } from './detail.js'; import { renderDetailShell, closeDetail } from './detail.js';
import {
getPluginView,
loadPlugins,
mountPluginSidebarSections,
pluginViews,
renderPluginSidebarSections,
renderPluginView,
} from './plugins.js';
/* ===================== state ===================== */ /* ===================== state ===================== */
const state = { const state = {
@ -56,14 +64,34 @@ const STATUS_FILTERS = [
/* ===================== bootstrap ===================== */ /* ===================== bootstrap ===================== */
async function boot() { async function boot() {
bindGlobal(); await ensureLogin();
await loadMeta(); // retries until the server answers await loadMeta(); // retries until the server answers
await loadPlugins(api, appContext());
renderPluginViewTabs();
bindGlobal();
syncAltToggle(); syncAltToggle();
renderSidebar(); renderSidebar();
renderView(); renderView();
api.stream(onSnapshot, onStatus); api.stream(onSnapshot, onStatus);
} }
function appContext() {
return {
api,
f,
state,
toast,
refreshMeta,
setView: switchViewTab,
renderView,
selectedHashes: () => [...state.selected],
selectHashes: (hashes) => {
state.selected = new Set(hashes || []);
renderGrid();
},
};
}
async function loadMeta() { async function loadMeta() {
for (;;) { for (;;) {
try { state.meta = await api.meta(); return; } try { state.meta = await api.meta(); return; }
@ -71,6 +99,51 @@ async function loadMeta() {
} }
} }
async function ensureLogin() {
const auth = await api.authStatus();
if (auth.authenticated) return;
document.getElementById('app').hidden = true;
await new Promise((resolve) => showLogin(auth, resolve));
document.getElementById('app').hidden = false;
}
function showLogin(auth, done) {
const host = document.createElement('div');
host.className = 'login-screen';
host.innerHTML = `
<form class="login-box" id="loginForm">
<div class="brand login-brand">
<span class="brand-mark"></span>
<span class="brand-name">NAUT</span>
<span class="brand-sub">torrent console</span>
</div>
<div class="field"><label>Username</label><input type="text" id="loginUser" value="${f.esc(auth.user || 'admin')}" autocomplete="username" /></div>
<div class="field"><label>Password</label><input type="password" id="loginPass" autocomplete="current-password" /></div>
<div class="login-error" id="loginError" hidden></div>
<button class="btn primary login-submit" type="submit">Sign in</button>
${auth.generatedPassword ? '<p class="dim login-note">A password was generated for this server process. Check the server console output.</p>' : ''}
</form>`;
document.body.appendChild(host);
const form = host.querySelector('#loginForm');
const err = host.querySelector('#loginError');
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
form.querySelector('.login-submit').disabled = true;
try {
await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value);
host.remove();
done();
} catch {
err.textContent = 'Invalid username or password';
err.hidden = false;
form.querySelector('.login-submit').disabled = false;
host.querySelector('#loginPass').select();
}
});
setTimeout(() => host.querySelector('#loginPass')?.focus(), 0);
}
function onSnapshot(snap) { function onSnapshot(snap) {
state.snapshot = snap; state.snapshot = snap;
// drop selection / detail for torrents that no longer exist // drop selection / detail for torrents that no longer exist
@ -157,12 +230,14 @@ function renderSidebar() {
const trackers = state.meta.trackers.slice(0, 10) const trackers = state.meta.trackers.slice(0, 10)
.map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join(''); .map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join('');
const pluginSections = renderPluginSidebarSections(appContext());
el.innerHTML = ` el.innerHTML = `
<div class="side-group"><div class="side-head">Status</div>${statusItems}</div> <div class="side-group"><div class="side-head">Status</div>${statusItems}</div>
<div class="side-group"><div class="side-head">Categories<button class="side-add" data-add="category" title="New category">+</button></div>${cats}</div> <div class="side-group"><div class="side-head">Categories<button class="side-add" data-add="category" title="New category">+</button></div>${cats}</div>
<div class="side-group"><div class="side-head">Tags<button class="side-add" data-add="tag" title="New tag">+</button></div>${tags || '<div class="side-item dim"><span class="lbl">none</span></div>'}</div> <div class="side-group"><div class="side-head">Tags<button class="side-add" data-add="tag" title="New tag">+</button></div>${tags || '<div class="side-item dim"><span class="lbl">none</span></div>'}</div>
<div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>`; <div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>
${pluginSections}`;
el.querySelectorAll('.side-item[data-type]').forEach((it) => { el.querySelectorAll('.side-item[data-type]').forEach((it) => {
it.addEventListener('click', () => { it.addEventListener('click', () => {
@ -178,6 +253,7 @@ function renderSidebar() {
el.querySelectorAll('.side-add[data-add]').forEach((b) => el.querySelectorAll('.side-add[data-add]').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); })); b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); }));
mountPluginSidebarSections(el, appContext());
} }
function onSidebarContext(e, type, value, removable) { function onSidebarContext(e, type, value, removable) {
@ -210,6 +286,7 @@ function renderView() {
if (state.view === 'rss') return renderRssView(host); if (state.view === 'rss') return renderRssView(host);
if (state.view === 'search') return renderSearchView(host); if (state.view === 'search') return renderSearchView(host);
if (state.view === 'settings') return renderSettingsView(host); if (state.view === 'settings') return renderSettingsView(host);
if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext());
} }
/* ---------- torrents view ---------- */ /* ---------- torrents view ---------- */
@ -528,10 +605,22 @@ function bdecode(buf) {
return parse(); return parse();
} }
function bytesToBase64(bytes) {
let bin = '';
const chunk = 0x8000; // avoid arg-count limits on String.fromCharCode
for (let i = 0; i < bytes.length; i += chunk)
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
return btoa(bin);
}
async function readTorrentFile(file) { async function readTorrentFile(file) {
const td = new TextDecoder('utf-8'); const td = new TextDecoder('utf-8');
const bytes = new Uint8Array(await file.arrayBuffer());
// The raw .torrent bytes are what the server actually adds; the parsed
// fields below are only for the modal's preview readout.
const data = bytesToBase64(bytes);
try { try {
const meta = bdecode(new Uint8Array(await file.arrayBuffer())); const meta = bdecode(bytes);
const info = meta.info; const info = meta.info;
const name = td.decode(info.name); const name = td.decode(info.name);
const pieceSize = info['piece length']; const pieceSize = info['piece length'];
@ -542,10 +631,10 @@ async function readTorrentFile(file) {
files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length })); files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length }));
size = files.reduce((a, fl) => a + fl.size, 0); size = files.reduce((a, fl) => a + fl.size, 0);
} }
return { name, size, pieceSize, pieceCount, files }; return { name, size, pieceSize, pieceCount, files, data };
} catch { } catch {
// Not parseable as bencode — fall back to the filename. // Not parseable as bencode — still upload the bytes; the server validates.
return { name: file.name.replace(/\.torrent$/i, '') }; return { name: file.name.replace(/\.torrent$/i, ''), data };
} }
} }
@ -880,6 +969,18 @@ function openModal(title, bodyHtml, buttons) {
function closeModal() { document.getElementById('modalBackdrop').hidden = true; } function closeModal() { document.getElementById('modalBackdrop').hidden = true; }
/* ===================== view tabs ===================== */ /* ===================== view tabs ===================== */
function renderPluginViewTabs() {
const nav = document.getElementById('viewtabs');
for (const view of pluginViews()) {
if (nav.querySelector(`[data-view="${CSS.escape(view.id)}"]`)) continue;
const button = document.createElement('button');
button.className = 'vtab plugin-vtab';
button.dataset.view = view.id;
button.textContent = view.label;
nav.appendChild(button);
}
}
function switchViewTab(view) { function switchViewTab(view) {
state.view = view; state.view = view;
document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view)); document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view));
@ -891,6 +992,7 @@ function bindGlobal() {
document.querySelectorAll('.toolbar [data-act]').forEach((b) => document.querySelectorAll('.toolbar [data-act]').forEach((b) =>
b.addEventListener('click', () => { b.addEventListener('click', () => {
const a = b.dataset.act; const a = b.dataset.act;
if (a === 'logout') return guard(async () => { await api.logout(); location.reload(); }, 'Failed to sign out');
if (a === 'add') return openAddModal(); if (a === 'add') return openAddModal();
doAction(a); doAction(a);
})); }));