theseus/bundled-addons/pithos/index.js
Local Dev 4ff75d312a Pithos 0.3.18: JSON key export, Linux desktop builds
Export JSON… gives scripts and agents one file with endpoint, key, secret
and drive. The desktop app now ships for Linux too (AppImage, .deb,
tar.gz), using per-user s3d paths there.
2026-10-04 22:49:48 +02:00

355 lines
17 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 fs = require("node:fs");
const crypto = require("node:crypto");
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),
// "Back to sidebar" from the full-tab view.
showPanel: () => api.revealSidebar("main"),
// "On Silent Mode": sign-in identity and file encryption keys come
// from the vault, so another computer with the same vault reaches
// and reads the same drives. NEVER CHANGE THESE PATHS: encrypted
// files would become unreadable.
secrets: {
kind: "vault",
identitySeed: () => vaultKey("pithos/hosted-identity/v1", "Sign in to your drives on Silent Mode."),
encryptionKey: () => vaultKey("pithos/hosted-encryption/v1", "Open your encrypted files on Silent Mode."),
},
});
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 };
});
// Viewer: a 10-minute inline link for one object (images, audio, video).
api.onMessage("view-url", async ({ path: apiPath } = {}) => {
const p = await ensureServer();
return { url: p.viewUrl(apiPath) };
});
// Footer updater: stage the newest signed Pithos from the extension
// channel, then (second click) ask Theseus to restart and apply it.
// Theseus asks the user before restarting. Older Theseus builds lack
// these hooks; the panel then points to Settings > Extensions.
api.onMessage("update", async ({ step = "stage" } = {}) => {
// In place, no restart (Theseus 0.3.77+): Theseus swaps the new version in,
// restarts the add-on and reloads this panel.
if (step === "apply-now") {
if (typeof api.applySelfUpdate !== "function") return { ok: false, reason: "needs a restart" };
return await api.applySelfUpdate();
}
if (step === "apply") {
if (typeof api.restartApp !== "function") return { fallback: "settings" };
return await api.restartApp();
}
if (typeof api.checkAndStageSelfUpdate !== "function") return { fallback: "settings" };
const r = await api.checkAndStageSelfUpdate();
const staged = r?.status === "staged" || r?.status === "already-staged";
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null, inPlace: typeof api.applySelfUpdate === "function" };
});
api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; });
// ---- pithos.sia/<user>/<drive>/<folder>: the app at its own address ----
// Theseus hands this add-on the requests for paths under pithos.sia (the
// root page stays the website). They go to the control server with the
// internal session, but only for pithos.sia's own requests and the
// address bar, never another site's fetch or form, and only while the
// vault is unlocked, the same as the sidebar panel.
const siteRoutes = typeof api.registerSiteRoute === "function";
if (siteRoutes) {
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
const FORWARD = ["content-type", "x-pithos", "range", "accept", "if-range"];
const DROP = new Set(["set-cookie", "connection", "keep-alive", "transfer-encoding"]);
api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => {
const u = new URL(request.url);
const fetchSite = request.headers.get("sec-fetch-site");
const origin = request.headers.get("origin");
const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none"
: !origin || /^(bns|https):\/\/pithos\.sia$/i.test(origin);
const st = await vaultStatus();
const locked = st.setup && st.prompt && !st.unlocked;
if (u.pathname === "/api/host/unlock") {
if (request.method !== "POST" || request.headers.get("x-pithos") !== "1" || !own) return json(403, { error: "forbidden" });
if (!locked) return json(200, { ok: true });
const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." });
return json(200, { ok: !!r.ok });
}
if (locked && u.pathname === "/api/session") return json(200, { authenticated: false, locked: true, passwordLogin: false, host: "theseus" });
const p = await ensureServer();
const headers = { "x-pithos-site": "1" };
for (const k of FORWARD) { const v = request.headers.get(k); if (v) headers[k] = v; }
if (own && !locked) headers.cookie = cookie;
const init = { method: request.method, headers, redirect: "manual" };
if (request.method !== "GET" && request.method !== "HEAD") init.body = Buffer.from(await request.arrayBuffer());
const res = await fetch(new URL(u.pathname + u.search, p.url), init);
if (res.status === 404 && res.headers.get("x-pithos-not-mine")) return null;
const out = new Headers();
for (const [k, v] of res.headers) if (!DROP.has(k.toLowerCase())) out.set(k, v);
return new Response(request.method === "HEAD" ? null : res.body, { status: res.status, headers: out });
} });
}
// An HTML file from a drive, opened as a page in a new Theseus tab. The
// control server sandboxes it (no access to Pithos or the session).
api.onMessage("open-page", async ({ path: pagePath } = {}) => {
if (!/^\/page\/[A-Za-z0-9_-]{20,}\/[^\s]*$/.test(String(pagePath || ""))) throw new Error("not a page link");
const p = await ensureServer();
api.openTab(new URL(pagePath, p.url).href);
return { ok: true };
});
// Full page: Pithos at pithos.sia/<user>/<drive>/… in an ordinary tab.
// An older Theseus without site routes gets the loopback address.
api.onMessage("open-in-tab", async ({ path: appPath } = {}) => {
const p = await ensureServer();
const clean = /^\/[^\s]*$/.test(String(appPath || "")) && appPath !== "/" ? appPath : "/drives";
api.openTab(siteRoutes ? `https://pithos.sia${clean}` : 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());
async function vaultKey(purpose, reason) {
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 });
if (!r.ok) throw new Error("The vault stayed locked.");
}
const bytes = await vault.derive(purpose);
const key = Buffer.from(bytes.subarray(0, 32));
bytes.fill(0);
return key;
}
// 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" },
});
});
// Left edge, beside the quick links. Theseus builds without left panels
// ignore `side` and show it in the right sidebar.
// ---- Saved credentials: vault-sealed files and the save dialog --------
// A credentials file protected "with my Theseus vault" is AES-256-GCM
// under a key derived from the vault, so it needs no extra password and
// opens only where that vault is unlocked. FIXED PATH: changing it makes
// every saved file unreadable.
async function credentialsKey() {
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: "Protect or open a saved Pithos credentials file." });
if (!r.ok) throw new Error("The vault stayed locked.");
}
return Buffer.from(await vault.derive("pithos/credentials/v1"));
}
api.onMessage("vault-seal", async ({ plaintext } = {}) => {
const key = await credentialsKey();
try {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([c.update(String(plaintext || ""), "utf8"), c.final(), c.getAuthTag()]);
return { iv: iv.toString("base64"), ct: ct.toString("base64") };
} finally { key.fill(0); }
});
api.onMessage("vault-open", async ({ iv, ct } = {}) => {
const key = await credentialsKey();
try {
const data = Buffer.from(String(ct || ""), "base64");
const d = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(String(iv || ""), "base64"));
d.setAuthTag(data.subarray(data.length - 16));
return { plaintext: Buffer.concat([d.update(data.subarray(0, data.length - 16)), d.final()]).toString("utf8") };
} catch {
throw new Error("This file was saved with a different Theseus vault.");
} finally { key.fill(0); }
});
// The sidebar page has no download path of its own; ask where to save.
api.onMessage("save-file", async ({ name, text } = {}) => {
const { dialog, BrowserWindow, app } = require("electron");
const safe = String(name || "pithos-credentials.pithoskey").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120);
const r = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow() || undefined, {
defaultPath: path.join(app.getPath("downloads"), safe),
filters: safe.endsWith(".json")
? [{ name: "JSON", extensions: ["json"] }]
: [{ name: "Pithos credentials", extensions: ["pithoskey"] }],
});
if (r.canceled || !r.filePath) return { saved: false };
fs.writeFileSync(r.filePath, String(text || ""), { mode: 0o600 });
return { saved: true };
});
// Start the control server with Theseus, so an s3d the user left running
// comes back after a restart or an update without opening Pithos first.
ensureServer().catch((e) => api.log("control server failed to start:", e.message));
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" });
// 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(); }
},
};