theseus/bundled-addons/pithos/index.js
Local Dev a3d7c90b78 Theseus: bundle Pithos 0.3.0 (PIN gate, vault-derived phrase, guided setup)
Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
2026-10-03 20:42:23 +02:00

190 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pithos for Theseus. The add-on runs the same control server as the web
// console and desktop app, in Theseus's main process, and shows the same UI
// as a left-sidebar panel. The panel is the add-on's own ui/index.html, so
// no loopback address ever appears in the address bar: the UI's API calls,
// uploads and live events travel over the add-on IPC bridge (invoke / emit),
// and this file forwards them to the server with an internal session.
//
// Loaded with require() by the add-on host; core/ is ESM, hence import().
const path = require("node:path");
const { pathToFileURL } = require("node:url");
let pithos = null;
let starting = null;
let cookie = null;
let events = null; // AbortController for the /api/events relay
module.exports = {
activate(api) {
// The pinned s3d release lands in the per-add-on data folder, never in the
// add-on folder itself (that one is replaced on update).
const dataDir = path.join(api.dataDir || path.join(api.folder, "..", "..", "extensions-data"), "pithos");
async function ensureServer() {
if (pithos) return pithos;
if (!starting) {
starting = (async () => {
const { createPithos } = await import(pathToFileURL(path.join(__dirname, "core", "server.js")).href);
const p = await createPithos({
host: "127.0.0.1",
port: 0,
hostName: "theseus",
binDir: path.join(dataDir, "bin"),
openExternal: (url) => api.openTab(url),
});
cookie = p.internalSession();
pithos = p;
api.log(`control server on ${p.url}`);
return p;
})().finally(() => { starting = null; });
}
return starting;
}
async function call(method, apiPath, { body, headers = {} } = {}) {
const p = await ensureServer();
const res = await fetch(new URL(apiPath, p.url), {
method,
headers: { cookie, "x-pithos": "1", ...headers },
body,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
return { status: res.status, data };
}
// Relay the server's event stream to the panel as api.emit events.
async function startEvents() {
if (events) return;
const p = await ensureServer();
const ctl = new AbortController();
events = ctl;
try {
const res = await fetch(new URL("/api/events", p.url), { headers: { cookie }, signal: ctl.signal });
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
const ev = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block);
if (ev && data) api.emit("pithos-event", { event: ev[1], data: JSON.parse(data[1]) });
}
}
} catch (e) {
if (!ctl.signal.aborted) api.log("event relay stopped:", e.message);
} finally {
if (events === ctl) events = null;
}
}
api.onMessage("api", ({ method, path: apiPath, body }) =>
call(method || "GET", apiPath, body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }));
// Uploads arrive as one ArrayBuffer over IPC; forward it as the PUT body.
api.onMessage("upload", ({ path: apiPath, bytes, type }) =>
call("PUT", apiPath, { body: Buffer.from(bytes), headers: { "content-type": type || "application/octet-stream" } }));
// Hand the file to Theseus's own download manager through a one-time link.
api.onMessage("download", async ({ path: apiPath }) => {
const p = await ensureServer();
const { session } = require("electron");
session.defaultSession.downloadURL(p.downloadUrl(apiPath));
return { ok: true };
});
api.onMessage("events-start", () => { startEvents(); return { ok: true }; });
api.onMessage("open-external", ({ url }) => {
if (!/^https:\/\//.test(url)) throw new Error("only https links");
api.openTab(url);
return { ok: true };
});
// A full browser-tab view, for anyone who prefers it to the sidebar.
api.onMessage("open-in-tab", async () => {
const p = await ensureServer();
api.openTab(p.authUrl);
return { ok: true };
});
// ---- Theseus vault: PIN gate and the vault-derived recovery phrase ------
// Older Theseus builds have the vault but no requestUnlock (no PIN prompt);
// Pithos then runs ungated, as before.
const vault = api.vault || null;
const canPrompt = !!(vault && typeof vault.requestUnlock === "function");
async function vaultStatus() {
if (!vault || !vault.lifecycle) return { setup: false, unlocked: false, prompt: false };
const st = await vault.lifecycle.status();
return { setup: !!st.setup, unlocked: !!st.unlocked, prompt: canPrompt };
}
api.onMessage("vault-status", () => vaultStatus());
// Watch for the vault locking (Settings › Lock now, or a lock from another
// extension) and tell the panel at once. This runs in the main process:
// a hidden panel's own timers are throttled to about once a minute.
if (canPrompt) {
let lastUnlocked = null;
setInterval(async () => {
try {
const st = await vaultStatus();
if (st.unlocked !== lastUnlocked) {
lastUnlocked = st.unlocked;
api.emit("pithos-vault", st);
}
} catch {}
}, 3000).unref?.();
}
// Pithos shows access-key secrets and can delete buckets, so in Theseus it
// opens only with the vault unlocked: Theseus asks for the PIN (or the
// master password after three wrong tries) in its own prompt.
api.onMessage("gate", async () => {
const st = await vaultStatus();
if (!st.setup || !st.prompt || st.unlocked) return { ok: true, ...st };
const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." });
return { ...r, ...(await vaultStatus()) };
});
// Connect with a recovery phrase derived from the vault: nothing to write
// down, and restoring the vault restores access. The phrase is built and
// handed to s3d here; it never reaches the panel page.
// CHANGING THE PURPOSE PATH OR THE DERIVATION CUTS USERS OFF FROM THEIR DATA.
api.onMessage("connect-with-vault", async ({ indexerUrl } = {}) => {
const st = await vaultStatus();
if (!st.setup) throw new Error("Set up the password vault in Settings › Passwords first.");
if (!st.unlocked) {
if (!canPrompt) throw new Error("Unlock the password vault in Settings › Passwords first.");
const r = await vault.requestUnlock({ reason: "Create the recovery phrase for your Sia storage." });
if (!r.ok) throw new Error("The vault stayed locked.");
}
const bytes = await vault.derive("pithos/sia-recovery/v1");
const bip39 = api.require("bip39");
const phrase = bip39.entropyToMnemonic(Buffer.from(bytes.subarray(0, 16)).toString("hex"));
bytes.fill(0);
return call("POST", "/api/login", {
body: JSON.stringify({ indexerUrl, phrase }),
headers: { "content-type": "application/json" },
});
});
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" });
// The host has no quit hook for add-ons; without this an s3d we started
// would outlive Theseus.
process.on("exit", () => { try { pithos && pithos.daemon.child && pithos.daemon.child.kill(); } catch {} });
},
async deactivate() {
if (events) { events.abort(); events = null; }
if (pithos) { const p = pithos; pithos = null; cookie = null; await p.close(); }
},
};