Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
// Pithos for Theseus. The add-on runs the same control server as the web
|
2026-10-03 19:34:53 +02:00
|
|
|
|
// 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.
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
//
|
|
|
|
|
|
// Loaded with require() by the add-on host; core/ is ESM, hence import().
|
|
|
|
|
|
|
|
|
|
|
|
const path = require("node:path");
|
2026-10-03 22:10:00 +02:00
|
|
|
|
const fs = require("node:fs");
|
|
|
|
|
|
const crypto = require("node:crypto");
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
const { pathToFileURL } = require("node:url");
|
|
|
|
|
|
|
|
|
|
|
|
let pithos = null;
|
|
|
|
|
|
let starting = null;
|
2026-10-03 19:34:53 +02:00
|
|
|
|
let cookie = null;
|
|
|
|
|
|
let events = null; // AbortController for the /api/events relay
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-03 19:34:53 +02:00
|
|
|
|
const p = await createPithos({
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
host: "127.0.0.1",
|
|
|
|
|
|
port: 0,
|
|
|
|
|
|
hostName: "theseus",
|
|
|
|
|
|
binDir: path.join(dataDir, "bin"),
|
|
|
|
|
|
openExternal: (url) => api.openTab(url),
|
Pithos 0.3.5: s3d stays up, one view at a time, user buckets, a viewer
- s3d kept stopping: Pithos stopped it whenever the extension restarted
(every update, every Theseus quit) and never started it again. Start
and Stop now record what the user wants (pithos-prefs.json beside
s3d.yml); Pithos starts s3d with Theseus and when opened, and retries a
crash up to three times in ten minutes, logging why it skips. The
Buckets page also redraws when s3d starts or stops; it used to keep
saying "not running".
- One view at a time: "Full page" opens Pithos in a tab and closes the
left panel; the tab has "Back to sidebar", which reopens the panel.
- Every new user gets a bucket of their own, named after them (lowercase,
hyphens) or as typed in the New user dialog.
- Buckets show as folder tiles. New folders are kept while empty with the
usual "<name>/" marker object.
- A viewer: images, audio (mp3 and friends), video and text open inside
Pithos, with Share and Download. The Theseus panel uses 10-minute,
reusable, inline view links so players can make range requests.
- The Access dialog says what s3d cannot do: per-folder public access and
write rights for others are not supported, only whole-bucket public read.
2026-10-04 00:05:49 +02:00
|
|
|
|
// "Back to sidebar" from the full-tab view.
|
|
|
|
|
|
showPanel: () => api.revealSidebar("main"),
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
|
// "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."),
|
|
|
|
|
|
},
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
});
|
2026-10-03 19:34:53 +02:00
|
|
|
|
cookie = p.internalSession();
|
|
|
|
|
|
pithos = p;
|
|
|
|
|
|
api.log(`control server on ${p.url}`);
|
|
|
|
|
|
return p;
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
})().finally(() => { starting = null; });
|
|
|
|
|
|
}
|
|
|
|
|
|
return starting;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 19:34:53 +02:00
|
|
|
|
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;
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
}
|
2026-10-03 19:34:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 };
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
});
|
Pithos 0.3.5: s3d stays up, one view at a time, user buckets, a viewer
- s3d kept stopping: Pithos stopped it whenever the extension restarted
(every update, every Theseus quit) and never started it again. Start
and Stop now record what the user wants (pithos-prefs.json beside
s3d.yml); Pithos starts s3d with Theseus and when opened, and retries a
crash up to three times in ten minutes, logging why it skips. The
Buckets page also redraws when s3d starts or stops; it used to keep
saying "not running".
- One view at a time: "Full page" opens Pithos in a tab and closes the
left panel; the tab has "Back to sidebar", which reopens the panel.
- Every new user gets a bucket of their own, named after them (lowercase,
hyphens) or as typed in the New user dialog.
- Buckets show as folder tiles. New folders are kept while empty with the
usual "<name>/" marker object.
- A viewer: images, audio (mp3 and friends), video and text open inside
Pithos, with Share and Download. The Theseus panel uses 10-minute,
reusable, inline view links so players can make range requests.
- The Access dialog says what s3d cannot do: per-folder public access and
write rights for others are not supported, only whole-bucket public read.
2026-10-04 00:05:49 +02:00
|
|
|
|
// 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) };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-04 02:38:19 +02:00
|
|
|
|
// 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" } = {}) => {
|
2026-10-04 15:28:26 +02:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
2026-10-04 02:38:19 +02:00
|
|
|
|
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";
|
2026-10-04 15:28:26 +02:00
|
|
|
|
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null, inPlace: typeof api.applySelfUpdate === "function" };
|
2026-10-04 02:38:19 +02:00
|
|
|
|
});
|
|
|
|
|
|
api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; });
|
|
|
|
|
|
|
2026-10-04 03:29:28 +02:00
|
|
|
|
// ---- 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 });
|
|
|
|
|
|
} });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Pithos: open HTML files as a page, or read them as text
An .html file in a drive could only be read as source. It can now also be
opened as a web page in a new tab (Theseus tab, browser tab, or the system
browser from the desktop app), with the CSS, images, scripts and pages
beside it loading as they would from a website.
The page is served under a sandbox CSP without allow-same-origin, so its
scripts run in an opaque origin: they cannot read the Pithos session or
reach its API, only the files of the folder the page link covers. Links
are random, last an hour from last use (12 hours at most), and refuse
paths that leave the folder. Pages decrypt on the fly for drives hosted
on Silent Mode.
2026-10-04 03:49:23 +02:00
|
|
|
|
// 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 };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-04 03:29:28 +02:00
|
|
|
|
// 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 } = {}) => {
|
2026-10-03 19:34:53 +02:00
|
|
|
|
const p = await ensureServer();
|
2026-10-04 03:29:28 +02:00
|
|
|
|
const clean = /^\/[^\s]*$/.test(String(appPath || "")) && appPath !== "/" ? appPath : "/drives";
|
|
|
|
|
|
api.openTab(siteRoutes ? `https://pithos.sia${clean}` : p.authUrl);
|
2026-10-03 19:34:53 +02:00
|
|
|
|
return { ok: true };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-03 20:42:21 +02:00
|
|
|
|
// ---- 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());
|
|
|
|
|
|
|
Pithos: run your drives on Silent Mode with your own Sia account
s3d binds one Sia account per process, so the server runs one s3d per
person (pithos-hosted), started on demand and flushed and stopped when idle.
Only the connected s3d data folder moves between PC and server; the Sia
recovery phrase never leaves the PC and nothing is re-uploaded to Sia.
Because the server holds the connection key, local Pithos encrypts names and
contents before they leave the machine, with keys from the Theseus vault (or
a local key file elsewhere). What s3d cannot do on its own, the gateway now
enforces: public folders, expiring share links and S3 keys for other people
limited to one folder with read, add-only or edit rights. Files those reach
are stored readable, since visitors have no key, and go back to encrypted
when the rule is removed. Sign-up needs a BNS name signature, a deposit or
an invite, with a per-IP limit.
2026-10-04 01:33:20 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 20:42:21 +02:00
|
|
|
|
// 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" },
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-03 20:48:54 +02:00
|
|
|
|
// Left edge, beside the quick links. Theseus builds without left panels
|
|
|
|
|
|
// ignore `side` and show it in the right sidebar.
|
2026-10-03 22:10:00 +02:00
|
|
|
|
// ---- 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: [{ name: "Pithos credentials", extensions: ["pithoskey"] }],
|
|
|
|
|
|
});
|
|
|
|
|
|
if (r.canceled || !r.filePath) return { saved: false };
|
|
|
|
|
|
fs.writeFileSync(r.filePath, String(text || ""), { mode: 0o600 });
|
|
|
|
|
|
return { saved: true };
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Pithos 0.3.5: s3d stays up, one view at a time, user buckets, a viewer
- s3d kept stopping: Pithos stopped it whenever the extension restarted
(every update, every Theseus quit) and never started it again. Start
and Stop now record what the user wants (pithos-prefs.json beside
s3d.yml); Pithos starts s3d with Theseus and when opened, and retries a
crash up to three times in ten minutes, logging why it skips. The
Buckets page also redraws when s3d starts or stops; it used to keep
saying "not running".
- One view at a time: "Full page" opens Pithos in a tab and closes the
left panel; the tab has "Back to sidebar", which reopens the panel.
- Every new user gets a bucket of their own, named after them (lowercase,
hyphens) or as typed in the New user dialog.
- Buckets show as folder tiles. New folders are kept while empty with the
usual "<name>/" marker object.
- A viewer: images, audio (mp3 and friends), video and text open inside
Pithos, with Share and Download. The Theseus panel uses 10-minute,
reusable, inline view links so players can make range requests.
- The Access dialog says what s3d cannot do: per-folder public access and
write rights for others are not supported, only whole-bucket public read.
2026-10-04 00:05:49 +02:00
|
|
|
|
// 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));
|
|
|
|
|
|
|
2026-10-03 20:48:54 +02:00
|
|
|
|
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" });
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
|
|
|
|
|
|
// 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() {
|
2026-10-03 19:34:53 +02:00
|
|
|
|
if (events) { events.abort(); events = null; }
|
|
|
|
|
|
if (pithos) { const p = pithos; pithos = null; cookie = null; await p.close(); }
|
Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
|
},
|
|
|
|
|
|
};
|