// 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'; import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js'; import { installHosted } from './hosted-routes.js'; import { accountInfo, readConnection, fingerprint } from './sia-account.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) secrets, // hosted identity + encryption keys; the Theseus vault, else a local key file } = 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; })(); let hosted = null; // "On Silent Mode", installed below 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; // While drives are hosted the UI follows the server's s3d instead. if (!hosted?.isHosted()) 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) { if (hosted.isHosted()) return hosted.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 decrypting = await hosted.s3For(user, keys); if (decrypting) return decrypting; 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(); const local = { 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(), }; return hosted.isHosted() ? hosted.hostedStatus(local) : local; }); // 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. // Which Sia account this is, as the indexer sees it (public key, storage, // last use), so it can be matched to an app on the sia.storage dashboard. // Asked at most once a minute; ?fresh=1 skips the cache. let siaCache = null; async function siaAccount(dir, fresh) { if (!fresh && siaCache && siaCache.dir === dir && Date.now() - siaCache.at < 60_000) return siaCache.info; let info; try { info = await accountInfo(dir); } catch (e) { info = { error: e.message }; } siaCache = { dir, at: Date.now(), info }; return info; } // An archive's key is read from its own s3d.db, without asking anyone. async function archiveKey(dir) { try { const c = await readConnection(dir); return c?.appKey ? fingerprint(c.appKey.subarray(32).toString('hex')) : null; } catch { return null; } } route('GET', '/api/account', async (req, url) => { const c = cfg(); const reg = await registration(); const archives = accounts.listArchives(c.directory); for (const a of archives) a.fingerprint = await archiveKey(path.join(c.directory, a.name)); return { indexerUrl: reg?.indexerUrl || null, registered: !!reg?.registered, label: accounts.readLabel(c.directory), dataDir: c.directory, sia: reg?.registered ? await siaAccount(c.directory, url.searchParams.get('fresh') === '1') : null, archives, }; }); 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 }; }); const autoFlush = createAutoFlush({ settings: () => { const p = readPrefs(configFile); return { enabled: p.autoFlush === true, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES }; }, // Only an s3d that is up can be asked; a stopped one just means no flush. stats: () => (daemon.state === 'starting' || daemon.state === 'stopping' ? Promise.reject(new Error('busy')) : admin.uploadStats(cfg())), flush: () => admin.flush(cfg()), log: (line) => daemon.pushLog('sys', line), }); route('GET', '/api/autoflush', () => autoFlush.status()); route('PUT', '/api/autoflush', async (req) => { const body = await jsonBody(req); const patch = {}; if (body.enabled !== undefined) patch.autoFlush = body.enabled === true; if (body.minutes !== undefined) patch.autoFlushMinutes = clampMinutes(body.minutes); writePrefs(configFile, patch); return autoFlush.status(); }); // 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>/)?.[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); // A decryption failure mid-file must not look like a complete download. up.on('error', () => res.destroy()); 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 // "/" 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>/)?.[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; if (hosted.isHosted()) return { url: await hosted.shareLink(p.user, p.bucket, requireKey(url), Number(q.get('expires') || 3600)) }; 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) => { if (hosted.isHosted()) return hosted.drivePolicy(p.bucket); 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' if (hosted.isHosted()) { if (!['private', 'read', 'read-list'].includes(mode)) throw new HttpError(400, 'mode must be private, read or read-list'); await hosted.setDrivePolicy(p.user, p.bucket, mode); return { ok: true }; } 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 }; }); hosted = installHosted({ route, configFile, cfg, daemon, broadcast, registration, HttpError, secrets, resetRegistration: () => { regCache = null; }, hasSubscribers: () => subscribers.size > 0, }); // ---- server ------------------------------------------------------------ const STREAMED = Symbol('streamed'); let boundPort = port; // Site mode: Theseus relays pithos.sia/ here (header x-pithos-site). // Pithos answers its own files, its pages and //…; 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) { // Hosted drives' users live on the server; the local s3d has none then. try { siteUsersCache = { at: Date.now(), set: new Set(hosted?.isHosted() ? await hosted.userNames() : 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= 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 (await hosted.intercept(req, res, url, (out) => sendJson(res, 200, out))) return; 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(hosted.isHosted() ? hosted.remoteDaemon() : 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 autoFlushTimer = setInterval(() => { autoFlush.tick(); }, 30_000); autoFlushTimer.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() { clearInterval(autoFlushTimer); hosted.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///… load the app's files from the root. if (base && file.endsWith('index.html')) data = Buffer.from(data.toString('utf8').replace('', ``)); res.end(req.method === 'HEAD' ? undefined : data); }); }