pithos/core/server.js
Local Dev cbf4ab7a1e Pithos 0.3.10: the app at pithos.sia/<user>/<drive>/<folder>
Full page opened Pithos at a loopback address such as
127.0.0.1:58678/#/buckets/music-house, which means nothing to anyone
reading it and changes every launch. In Theseus the app now lives at
pithos.sia/<S3 user>/<drive>/<folder>, with pages at pithos.sia/settings,
/account and so on; the root page stays the pithos.sia website.

The add-on answers those paths through Theseus's new site routes. It
passes the session to the control server only for pithos.sia's own
requests and the address bar, never another site's fetch, and only
while the vault is unlocked, as the sidebar panel requires. The server
answers its own files, pages and existing S3 users and reports
anything else as not its own, so the website answers it. The sidebar
and the web console keep #/ addresses; an older Theseus still gets the
loopback address.
2026-10-04 03:29:28 +02:00

695 lines
31 KiB
JavaScript

// The Pithos control server: one JSON API + the static UI. Every host (web
// console, desktop window, Theseus tab) runs exactly this and points a view
// at it, so features are written once.
//
// Auth: a session secret. Local hosts pass it in the first URL (?t=...);
// the web console can instead accept a password. Either way it is exchanged
// for an HttpOnly SameSite=Strict cookie, and mutating requests also need the
// x-pithos header so a cross-site form can never drive the API.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { resolveConfigPath, resolveBinary } from './paths.js';
import { readConfig, writeConfig, ensureConfig, EDITABLE } from './config.js';
import { Daemon } from './daemon.js';
import { makeCli, CliError } from './cli.js';
import { LoginSession } from './login.js';
import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js';
import * as admin from './admin.js';
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
import * as accounts from './accounts.js';
import { readPrefs, writePrefs } from './prefs.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const UI_DIR = path.join(HERE, '..', 'ui');
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json',
'.woff2': 'font/woff2',
};
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
export async function createPithos(opts = {}) {
const {
host = '127.0.0.1',
port = 0,
configFile: configOpt,
binary: binaryOpt,
binDir, // where installS3d puts the pinned release
password, // web console: optional login password
allowedHosts = [], // extra Host header values (reverse proxy names)
hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only
openExternal, // (url) => void, for hosts that can open the system browser
showPanel, // () => void, for hosts with a side panel to switch back to (Theseus)
} = opts;
const configFile = resolveConfigPath(configOpt);
const daemon = new Daemon({ configFile, binary: resolveBinary(binaryOpt, binDir) });
const cli = makeCli(daemon);
const login = new LoginSession(daemon, {
registration: () => cli.registration(),
// Hosts without node-pty (the Theseus add-on) fetch it here on first login.
ptyDir: binDir ? path.join(binDir, '..', 'pty') : null,
});
const secret = crypto.randomBytes(24).toString('base64url');
const sessions = new Set();
// One-time download links: id -> { path, exp }. Lets a host hand a file
// to its own download manager, which carries no session cookie.
const tickets = new Map();
const subscribers = new Set();
let versionCache = null;
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
const pithosVersion = (() => {
for (const f of ['../addon.json', '../package.json']) {
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {}
}
return null;
})();
const broadcast = (event, data) => {
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const res of subscribers) res.write(msg);
};
daemon.on('status', (s) => {
// A registration done outside Pithos (s3d login by hand) shows up on the next start.
if (s.state === 'starting' && !regCache?.registered) regCache = null;
broadcast('status', s);
});
daemon.on('log', (l) => broadcast('log', l));
login.on('state', (s) => {
if (s.state === 'done' || s.state === 'already') regCache = { registered: true, indexerUrl: s.indexerUrl || regCache?.indexerUrl };
broadcast('login', s);
});
// Registration costs an s3d process run, so it is cached and only
// recomputed when something that could change it happens.
let regCache = null;
let lastAutoTry = 0;
async function registration() {
// A "not connected" answer is re-checked after 30 s: the connection can
// be made outside Pithos (s3d login by hand, another Pithos).
if (regCache && !regCache.registered && Date.now() - (regCache.at || 0) > 30_000) regCache = null;
if (!regCache && daemon.binary) {
const r = await cli.registration().catch(() => null);
regCache = r ? { ...r, at: Date.now() } : null;
}
return regCache;
}
function cfg() { return readConfig(configFile); }
async function version() {
if (!daemon.binary) return null;
if (versionCache?.binary === daemon.binary) return versionCache.info;
try {
const info = await cli.version();
versionCache = { binary: daemon.binary, info };
return info;
} catch (e) {
return { version: null, error: e.message };
}
}
// An s3d we did not start (a service, or one run by hand) that answers on
// the configured admin address.
async function probeExternal(c) {
if (daemon.child || !c.adminPassword) return false;
try { await admin.uploadStats(c); return true; } catch { return false; }
}
async function s3For(user) {
const keys = await cli.listKeys(user);
if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`);
const c = cfg();
return new S3Client({ endpoint: `http://${c.apiAddress}`, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey });
}
// ---- routing ---------------------------------------------------------
const routes = [];
const route = (method, pattern, handler) => {
const keys = [];
const re = new RegExp('^' + pattern.replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '$');
routes.push({ method, re, keys, handler });
};
route('GET', '/api/status', async () => {
const c = cfg();
// Backstop for the start-up autostart: opening Pithos also brings s3d
// back if the user wants it running (at most every 30 s).
if (!daemon.child && daemon.state !== 'starting' && Date.now() - lastAutoTry > 30_000 && readPrefs(configFile).autostart === true) {
lastAutoTry = Date.now();
autostart('Pithos was opened');
}
const v = await version();
return {
host: hostName,
pithos: pithosVersion,
daemon: daemon.status(),
external: await probeExternal(c),
s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) },
config: { file: c.file, exists: c.exists, apiAddress: c.apiAddress, adminAddress: c.adminAddress, directory: c.directory, https: c.data.apiHTTPSAddress || null },
login: login.snapshot(),
registration: await registration(),
};
});
// Start and Stop also record what the user wants, so s3d comes back by
// itself after Theseus restarts or Pithos is updated.
route('POST', '/api/daemon/start', async () => {
ensureConfig(configFile);
writePrefs(configFile, { autostart: true });
return daemon.start();
});
route('POST', '/api/daemon/stop', () => { writePrefs(configFile, { autostart: false }); return daemon.stop(); });
route('POST', '/api/daemon/restart', async () => {
await daemon.stop();
ensureConfig(configFile);
return daemon.start();
});
route('GET', '/api/logs', (req, url) => daemon.logsSince(Number(url.searchParams.get('since') || 0)));
route('POST', '/api/binary/install', async () => {
if (daemon.child) throw new HttpError(409, 'stop s3d before replacing its binary');
if (!binDir) throw new HttpError(400, 'this host has no writable bin folder');
const target = await installS3d(binDir, (p) => broadcast('install', p));
daemon.binary = target;
versionCache = null;
regCache = null;
return { binary: target, version: await version() };
});
route('GET', '/api/config', () => {
const c = cfg();
const values = {};
for (const k of EDITABLE) values[k] = getIn(c.data, k.split('.')) ?? null;
return { file: c.file, exists: c.exists, values };
});
route('PUT', '/api/config', async (req) => {
const patch = await jsonBody(req);
const c = writeConfig(configFile, patch);
if ('directory' in patch) regCache = null;
return { file: c.file, restartNeeded: !!daemon.child };
});
// Login (indexer registration)
route('GET', '/api/login', () => login.snapshot());
route('POST', '/api/login', async (req) => {
const { indexerUrl, phrase } = await jsonBody(req);
if (phrase && phrase.trim().split(/\s+/).length !== 12) throw new HttpError(400, 'a recovery phrase is 12 words');
if (indexerUrl && !/^https?:\/\/[^\s]+$/.test(indexerUrl)) throw new HttpError(400, 'indexer URL must be http(s)');
ensureConfig(configFile);
return login.start({ indexerUrl: indexerUrl?.trim(), phrase: phrase?.trim().toLowerCase().split(/\s+/).join(' ') });
});
route('POST', '/api/login/confirm', () => { login.confirmGenerated(); return login.snapshot(); });
route('POST', '/api/login/cancel', () => { login.cancel(); return login.snapshot(); });
route('POST', '/api/open-external', async (req) => {
const { url } = await jsonBody(req);
if (!openExternal) throw new HttpError(400, 'not supported by this host');
if (!/^https:\/\//.test(url)) throw new HttpError(400, 'only https links');
openExternal(url);
return { ok: true };
});
// Users & keys
route('GET', '/api/users', async () => {
const [users, keys] = await Promise.all([cli.listUsers(), cli.listKeys()]);
return users.map((name) => ({ name, keys: keys.filter((k) => k.user === name).map((k) => k.accessKeyId) }));
});
// Every user gets a bucket of their own (named after them unless the
// caller picks a name), so there is somewhere to put files straight away.
route('POST', '/api/users', async (req) => {
const { name, withKey = true, bucket } = await jsonBody(req);
await cli.createUser(name);
const key = withKey ? await cli.createKey(name) : null;
let created = null;
let bucketError = null;
if (key && bucket !== false && daemon.state === 'running') {
const client = new S3Client({ endpoint: `http://${cfg().apiAddress}`, accessKeyId: key.accessKeyId, secretKey: key.secretKey });
const base = bucket ? String(bucket) : bucketNameFor(name);
for (let i = 0; i < 5 && !created; i++) {
const candidate = i ? `${base.slice(0, 60)}-${i + 1}` : base;
try { await client.createBucket(candidate); created = candidate; }
catch (e) {
if (e.code === 'BucketAlreadyExists' || e.code === 'BucketAlreadyOwnedByYou') continue;
bucketError = e.message;
break;
}
}
}
return { name, key, bucket: created, bucketError };
});
route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; });
route('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined));
route('POST', '/api/keys', async (req) => {
const { user, accessKey, secretKey } = await jsonBody(req);
return cli.createKey(user, { accessKey, secretKey });
});
route('DELETE', '/api/keys/:id', async (req, url, p) => { await cli.deleteKey(p.id); return { ok: true }; });
// Upload pipeline
// Sia account: which one this s3d uses, a user-set label (Pithos cannot
// see the account's email), switching to another and restoring an old one.
route('GET', '/api/account', async () => {
const c = cfg();
const reg = await registration();
return {
indexerUrl: reg?.indexerUrl || null,
registered: !!reg?.registered,
label: accounts.readLabel(c.directory),
dataDir: c.directory,
archives: accounts.listArchives(c.directory),
};
});
route('PUT', '/api/account/label', async (req) => {
const { label } = await jsonBody(req);
return { label: accounts.writeLabel(cfg().directory, label) };
});
async function accountGuard() {
if (await probeExternal(cfg())) throw new HttpError(409, 'another s3d process is serving this config; stop it first');
if (login.state !== 'idle' && !['done', 'already', 'failed'].includes(login.state)) throw new HttpError(409, 'a Sia login is in progress');
}
async function currentMeta() {
const reg = await registration().catch(() => null);
const users = await cli.listUsers().catch(() => []);
return { indexerUrl: reg?.indexerUrl || null, users };
}
route('POST', '/api/account/switch', async () => {
await accountGuard();
const meta = await currentMeta();
await daemon.stop();
const r = accounts.archiveCurrent(cfg().directory, meta);
regCache = null;
login.cancel();
return { archived: r.name, path: r.path };
});
route('POST', '/api/account/restore', async (req) => {
const { name } = await jsonBody(req);
await accountGuard();
const meta = await currentMeta();
await daemon.stop();
const r = accounts.restoreArchive(cfg().directory, String(name || ''), meta);
regCache = null;
login.cancel();
return r;
});
route('GET', '/api/stats', () => admin.uploadStats(cfg()));
route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; });
// Buckets & objects, acting as one s3d user.
route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets());
route('POST', '/api/s3/:user/buckets', async (req, url, p) => {
const { name } = await jsonBody(req);
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(name || '')) throw new HttpError(400, 'bucket names are 3-63 chars: lowercase letters, digits, dots, hyphens');
await (await s3For(p.user)).createBucket(name);
return { name };
});
route('DELETE', '/api/s3/:user/buckets/:bucket', async (req, url, p) => {
await (await s3For(p.user)).deleteBucket(p.bucket);
return { ok: true };
});
route('GET', '/api/s3/:user/buckets/:bucket/objects', async (req, url, p) => {
const q = url.searchParams;
return (await s3For(p.user)).listObjects(p.bucket, { prefix: q.get('prefix') || '', token: q.get('token') || undefined });
});
route('PUT', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => {
const key = requireKey(url);
const len = req.headers['content-length'];
if (len == null) throw new HttpError(411, 'Content-Length required');
if (Number(len) > 5 * 1024 ** 3) throw new HttpError(413, 'single uploads are limited to 5 GiB');
await (await s3For(p.user)).putObject(p.bucket, key, req, { contentType: req.headers['content-type'], contentLength: len });
return { key };
});
route('GET', '/api/s3/:user/buckets/:bucket/object', async (req, url, p, res) => {
const key = requireKey(url);
const up = await (await s3For(p.user)).getObject(p.bucket, key, { range: req.headers.range });
if (up.statusCode >= 300 && up.statusCode !== 304) {
throw new HttpError(up.statusCode, (await readBody(up)).match(/<Message>(.*?)<\/Message>/)?.[1] || `HTTP ${up.statusCode}`);
}
const headers = {};
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']) if (up.headers[h]) headers[h] = up.headers[h];
const name = key.split('/').pop() || 'download';
headers['content-disposition'] = `${url.searchParams.get('inline') ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`;
headers['x-content-type-options'] = 'nosniff';
// Objects are user content: never let them run script on this origin.
headers['content-security-policy'] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'";
res.writeHead(up.statusCode, headers);
up.pipe(res);
return STREAMED;
});
route('DELETE', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => {
await (await s3For(p.user)).deleteObject(p.bucket, requireKey(url));
return { ok: true };
});
route('DELETE', '/api/s3/:user/buckets/:bucket/prefix', async (req, url, p) => {
const prefix = url.searchParams.get('prefix');
if (!prefix) throw new HttpError(400, 'prefix required');
return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) };
});
// S3 has no folders, only names with slashes. An empty object named
// "<folder>/" is the usual marker that keeps an empty folder visible.
route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => {
let prefix = url.searchParams.get('prefix') || '';
if (!prefix || prefix.startsWith('/') || prefix.includes('//')) throw new HttpError(400, 'folder name required');
if (!prefix.endsWith('/')) prefix += '/';
await (await s3For(p.user)).putObject(p.bucket, prefix, Buffer.alloc(0), { contentLength: 0 });
return { prefix };
});
// Small text files for the viewer (first 512 KiB).
route('GET', '/api/s3/:user/buckets/:bucket/text', async (req, url, p) => {
const up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' });
const body = await readBody(up);
if (up.statusCode >= 300) throw new HttpError(up.statusCode, body.match(/<Message>(.*?)<\/Message>/)?.[1] || `HTTP ${up.statusCode}`);
const total = Number((up.headers['content-range'] || '').split('/')[1]) || body.length;
return { text: body, truncated: total > 524288, size: total };
});
// Drive usage: files and bytes in a bucket, counted up to 20 pages.
route('GET', '/api/s3/:user/buckets/:bucket/usage', async (req, url, p) => {
const client = await s3For(p.user);
let files = 0, bytes = 0, token, pages = 0;
do {
const page = await client.listObjects(p.bucket, { delimiter: '', token });
for (const o of page.objects) { if (!o.key.endsWith('/')) { files++; bytes += o.size || 0; } }
token = page.truncated ? page.nextToken : null;
} while (token && ++pages < 20);
return { files, bytes, partial: !!token };
});
route('POST', '/api/host/show-panel', () => {
if (!showPanel) throw new HttpError(400, 'not supported by this host');
showPanel();
return { ok: true };
});
route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => {
const q = url.searchParams;
const client = await s3For(p.user);
return { url: client.presign(p.bucket, requireKey(url), Number(q.get('expires') || 3600), q.get('endpoint') || undefined) };
});
route('GET', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
const policy = await (await s3For(p.user)).getPolicy(p.bucket);
return { policy, public: describePolicy(policy) };
});
route('PUT', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
const { mode } = await jsonBody(req); // 'private' | 'read' | 'read-list'
const client = await s3For(p.user);
if (mode === 'private') { await client.deletePolicy(p.bucket).catch((e) => { if (e.status !== 404) throw e; }); }
else if (mode === 'read' || mode === 'read-list') await client.putPolicy(p.bucket, publicReadPolicy(p.bucket, { list: mode === 'read-list' }));
else throw new HttpError(400, 'mode must be private, read or read-list');
return { ok: true };
});
// ---- server ------------------------------------------------------------
const STREAMED = Symbol('streamed');
let boundPort = port;
// Site mode: Theseus relays pithos.sia/<path> here (header x-pithos-site).
// Pithos answers its own files, its pages and /<S3 user>/…; anything else
// is "not mine", so the pithos.sia website answers it instead.
const SITE_PAGES = new Set(['overview', 'drives', 'users', 'account', 'start', 'setup', 'settings', 'logs']);
const SITE_FILES = new Set(['app.js', 'app.css', 'icon.svg']);
let siteUsersCache = { at: 0, set: new Set() };
async function siteUsers() {
if (Date.now() - siteUsersCache.at > 10_000) {
try { siteUsersCache = { at: Date.now(), set: new Set(await cli.listUsers()) }; }
catch { siteUsersCache.at = Date.now(); } // keep the last good list
}
return siteUsersCache.set;
}
async function serveSite(req, res, url) {
const segs = url.pathname.split('/').filter(Boolean);
let first = '';
try { first = decodeURIComponent(segs[0] || ''); } catch {}
if (segs.length === 1 && SITE_FILES.has(first)) return serveStatic(req, res, url);
if (!(SITE_PAGES.has(first) || (await siteUsers()).has(first))) {
res.writeHead(404, { 'x-pithos-not-mine': '1' });
return res.end('not found');
}
return serveStatic(req, res, new URL('/', url), { base: '/' });
}
function hostAllowed(h) {
if (!h) return false;
const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts];
return ok.includes(h.toLowerCase());
}
function cookieSession(req) {
const m = /(?:^|;\s*)pithos=([^;]+)/.exec(req.headers.cookie || '');
return m && sessions.has(m[1]) ? m[1] : null;
}
function issueSession(res) {
const id = crypto.randomBytes(24).toString('base64url');
sessions.add(id);
res.setHeader('set-cookie', `pithos=${id}; HttpOnly; SameSite=Strict; Path=/`);
return id;
}
const server = http.createServer(async (req, res) => {
// DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still
// sends its own Host header.
if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); }
let url = new URL(req.url, `http://${req.headers.host}`);
let viaTicket = false;
const dl = /^\/(dl|v)\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname);
if (dl && (req.method === 'GET' || req.method === 'HEAD')) {
const t = tickets.get(dl[2]);
if (dl[1] === 'dl' || (t && !t.reuse)) tickets.delete(dl[2]);
if (!t || t.exp < Date.now()) { res.writeHead(410); return res.end('link expired'); }
url = new URL(t.path, url);
viaTicket = true;
}
res.setHeader('x-frame-options', 'DENY');
res.setHeader('referrer-policy', 'no-referrer');
try {
// Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie.
const t = url.searchParams.get('t');
if (t && safeEqual(t, secret) && req.method === 'GET' && !url.pathname.startsWith('/api/')) {
issueSession(res);
res.writeHead(302, { location: url.pathname });
return res.end();
}
if (url.pathname === '/api/session') {
if (req.method === 'GET') return sendJson(res, 200, { authenticated: !!cookieSession(req), passwordLogin: !!password, host: hostName });
if (req.method === 'POST') {
requireCsrf(req);
const body = await jsonBody(req);
if (!password || !safeEqual(String(body.password || ''), password)) {
await new Promise((r) => setTimeout(r, 750));
throw new HttpError(401, 'wrong password');
}
issueSession(res);
return sendJson(res, 200, { authenticated: true });
}
if (req.method === 'DELETE') {
const s = cookieSession(req);
if (s) sessions.delete(s);
res.setHeader('set-cookie', 'pithos=; Max-Age=0; Path=/');
return sendJson(res, 200, { authenticated: false });
}
}
if (url.pathname.startsWith('/api/')) {
if (!viaTicket && !cookieSession(req)) throw new HttpError(401, 'not signed in');
if (req.method !== 'GET') requireCsrf(req);
if (url.pathname === '/api/events') {
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
res.write(`event: status\ndata: ${JSON.stringify(daemon.status())}\n\n`);
res.write(`event: login\ndata: ${JSON.stringify(login.snapshot())}\n\n`);
subscribers.add(res);
const ping = setInterval(() => res.write(': ping\n\n'), 25_000);
req.on('close', () => { clearInterval(ping); subscribers.delete(res); });
return;
}
for (const r of routes) {
if (r.method !== req.method) continue;
const m = r.re.exec(url.pathname);
if (!m) continue;
const params = Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])]));
const out = await r.handler(req, url, params, res);
if (out === STREAMED) return;
return sendJson(res, 200, out ?? { ok: true });
}
throw new HttpError(404, 'no such endpoint');
}
if (req.headers['x-pithos-site'] === '1') return await serveSite(req, res, url);
return serveStatic(req, res, url);
} catch (err) {
const status = err instanceof HttpError ? err.status
: err instanceof S3Error ? (err.status >= 400 && err.status < 600 ? err.status : 502)
: err instanceof CliError ? 400
: 500;
if (status === 500) console.error('[pithos]', err);
if (res.headersSent) return res.destroy();
sendJson(res, status, { error: err.message, code: err.code });
}
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, resolve);
});
boundPort = server.address().port;
// Bring s3d back if the user had it running. A crash (not a Stop) is
// retried a few times with a pause, but never a missing Sia connection.
let restarts = [];
lastAutoTry = Date.now();
const wantRunning = () => readPrefs(configFile).autostart === true;
async function autostart(reason) {
if (daemon.child) return;
if (!wantRunning()) return; // the user stopped it, or never started it
if (!daemon.binary) { daemon.pushLog('sys', 'not starting s3d: it is not installed'); return; }
if (await probeExternal(cfg())) { daemon.pushLog('sys', 'not starting s3d: another s3d already serves this config'); return; }
try { ensureConfig(configFile); daemon.start(); daemon.pushLog('sys', `started by Pithos (${reason})`); }
catch (e) { daemon.pushLog('sys', `could not start s3d: ${e.message}`); }
}
daemon.on('status', (st) => {
if (st.state !== 'crashed' || st.needsLogin || !wantRunning()) return;
restarts = restarts.filter((t) => Date.now() - t < 10 * 60_000);
if (restarts.length >= 3) { daemon.pushLog('sys', 's3d keeps stopping; not restarting again for now. See the log above.'); return; }
restarts.push(Date.now());
setTimeout(() => autostart('it stopped unexpectedly'), 5000).unref?.();
});
setTimeout(() => autostart('it was running before'), 500).unref?.();
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
return {
server,
daemon,
url: baseUrl,
// Opening this URL signs the view in.
authUrl: `${baseUrl}?t=${secret}`,
// For hosts that call the API themselves (the Theseus sidebar bridge):
// a session cookie that never touches a browser.
internalSession() {
const id = crypto.randomBytes(24).toString('base64url');
sessions.add(id);
return `pithos=${id}`;
},
// A single-use, 60-second URL for one object download.
// A 10-minute link that streams one object inline, for the viewer in
// hosts whose page has no session (the Theseus panel).
viewUrl(apiPath) {
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only objects');
const id = crypto.randomBytes(24).toString('base64url');
tickets.set(id, { path: apiPath + '&inline=1', exp: Date.now() + 10 * 60_000, reuse: true });
return `${baseUrl}v/${id}`;
},
downloadUrl(apiPath) {
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads');
const id = crypto.randomBytes(24).toString('base64url');
tickets.set(id, { path: apiPath, exp: Date.now() + 60_000 });
for (const [k, v] of tickets) if (v.exp < Date.now()) tickets.delete(k);
return `${baseUrl}dl/${id}`;
},
async close() {
login.cancel();
for (const s of subscribers) s.end();
await daemon.stop();
await new Promise((r) => server.close(r));
},
};
}
// ---- helpers -------------------------------------------------------------
function sendJson(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(data);
}
function requireCsrf(req) {
if (req.headers['x-pithos'] !== '1') throw new HttpError(403, 'missing x-pithos header');
}
function requireKey(url) {
const key = url.searchParams.get('key');
if (!key) throw new HttpError(400, 'key required');
return key;
}
async function jsonBody(req) {
const chunks = [];
let size = 0;
for await (const c of req) {
size += c.length;
if (size > 1 << 20) throw new HttpError(413, 'body too large');
chunks.push(c);
}
if (!chunks.length) return {};
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'invalid JSON'); }
}
function safeEqual(a, b) {
const x = Buffer.from(String(a));
const y = Buffer.from(String(b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function getIn(obj, keys) {
return keys.reduce((o, k) => (o == null ? undefined : o[k]), obj);
}
// A valid S3 bucket name from a user name: lowercase letters, digits and
// hyphens, 3-63 characters.
function bucketNameFor(name) {
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (b.length < 3) b = (b || 'my') + '-files';
return b.slice(0, 63).replace(/-+$/, '');
}
function cmpVersion(a, b) {
const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0);
const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
return 0;
}
function describePolicy(policy) {
if (!policy) return 'private';
const actions = new Set();
for (const s of [].concat(policy.Statement || [])) {
if (s.Effect !== 'Allow') continue;
if (s.Principal !== '*' && s.Principal?.AWS !== '*') continue;
for (const a of [].concat(s.Action || [])) actions.add(a);
}
if (actions.has('s3:ListBucket')) return 'read-list';
if (actions.has('s3:GetObject')) return 'read';
return 'custom';
}
function serveStatic(req, res, url, { base } = {}) {
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); }
let rel = decodeURIComponent(url.pathname);
if (rel === '/' || !path.extname(rel)) rel = '/index.html';
const file = path.join(UI_DIR, path.normalize(rel));
if (!file.startsWith(UI_DIR + path.sep)) { res.writeHead(403); return res.end(); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, {
'content-type': MIME[path.extname(file)] || 'application/octet-stream',
'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
});
// Pages under pithos.sia/<user>/<drive>/… load the app's files from the root.
if (base && file.endsWith('index.html')) data = Buffer.from(data.toString('utf8').replace('<head>', `<head><base href="${base}">`));
res.end(req.method === 'HEAD' ? undefined : data);
});
}