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

998 lines
49 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';
import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js';
import { installHosted } from './hosted-routes.js';
import { accountInfo, readConnection, fingerprint } from './sia-account.js';
import { guessType } from './hosted.js';
import { accountSpace } from './space.js';
import { createShareUrl } from './sia-share.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,
// electron-builder's portable exe sets this; updates then fetch the portable build.
portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE,
platform: process.platform,
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);
// On unless the user turned it off: files left in the buffer otherwise
// never reach Sia, and nobody expects to have to push them.
return { enabled: p.autoFlush !== false, 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>(.*?)<\/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) };
});
// ---- renames: files, folders, drives -----------------------------------
// Server-side copies, so they are quick and use no extra Sia storage.
// Every client Pithos makes can rename; the check stays for any that
// cannot (encrypted names and contents are tied to their path, so a raw
// server-side copy would break them).
async function renamer(user) {
const s3 = await s3For(user);
if (typeof s3.renamePrefix !== 'function') throw new HttpError(409, 'Renaming is not available for drives on Silent Mode yet.');
return s3;
}
const badName = (n) => !n || n.includes('/') || n === '.' || n === '..';
async function exists(s3, bucket, prefix) {
const page = await s3.listObjects(bucket, { prefix, delimiter: '', max: 1 });
return page.objects.some((o) => o.key === prefix || prefix.endsWith('/'));
}
// A file: { from: "<key>", name: "<new name>" } in the same folder.
route('POST', '/api/s3/:user/buckets/:bucket/rename-object', async (req, url, p) => {
const { from, name } = await jsonBody(req);
if (!from || from.endsWith('/') || badName(name)) throw new HttpError(400, 'a file and a new name (without "/") are required');
const to = from.slice(0, from.lastIndexOf('/') + 1) + name;
if (to === from) return { key: to };
const s3 = await renamer(p.user);
if (await exists(s3, p.bucket, to)) throw new HttpError(409, `"${name}" already exists here`);
await s3.renameObject(p.bucket, from, to);
return { key: to };
});
// A folder: { from: "<prefix>/", name: "<new name>" } beside it.
route('POST', '/api/s3/:user/buckets/:bucket/rename-prefix', async (req, url, p) => {
const { from, name } = await jsonBody(req);
if (!from || !from.endsWith('/') || badName(name)) throw new HttpError(400, 'a folder and a new name (without "/") are required');
const parent = from.slice(0, from.slice(0, -1).lastIndexOf('/') + 1);
const to = `${parent}${name}/`;
if (to === from) return { prefix: to, moved: 0 };
const s3 = await renamer(p.user);
if (await exists(s3, p.bucket, to)) throw new HttpError(409, `a folder "${name}" already exists here`);
return { prefix: to, moved: await s3.renamePrefix(p.bucket, from, to) };
});
// A drive: a new bucket gets every file and the same access, then the old
// one goes. S3 apps that use the old name need the new one.
route('POST', '/api/s3/:user/buckets/:bucket/rename', async (req, url, p) => {
const { name } = await jsonBody(req);
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(String(name || ''))) throw new HttpError(400, 'drive names are 3-63 lowercase letters, digits, dots and hyphens');
if (name === p.bucket) return { name };
const s3 = await renamer(p.user);
if ((await s3.listBuckets()).some((b) => b.name === name)) throw new HttpError(409, `a drive "${name}" already exists`);
await s3.createBucket(name);
// Public access carries over, rebuilt for the new name (a policy names its bucket).
const mode = describePolicy(await s3.getPolicy(p.bucket).catch(() => null));
const moved = await s3.renamePrefix(p.bucket, '', '', name);
if (mode === 'read' || mode === 'read-list') await s3.putPolicy(name, publicReadPolicy(name, { list: mode === 'read-list' }));
await s3.deleteBucket(p.bucket);
return { name, moved };
});
// ---- public links: navigate.st/sia/<id>/<name> -----------------------------------
// A file in your own Sia account gets a Sia shared-object URL (it reads that
// one file and nothing else), registered at navigate.st, which streams it
// from Sia for anyone. Files still waiting on this computer go up first.
// The delete tokens stay here, beside s3d.yml, so links can be switched off.
const SIA_LINKS_API = process.env.PITHOS_SIA_LINKS_API || 'https://navigate.st/api/sia/links';
const siaLinksFile = () => path.join(path.dirname(configFile), 'pithos-sia-links.json');
const readSiaLinks = () => { try { return JSON.parse(fs.readFileSync(siaLinksFile(), 'utf8')); } catch { return []; } };
const writeSiaLinks = (list) => fs.writeFileSync(siaLinksFile(), JSON.stringify(list, null, 2), { mode: 0o600 });
async function shareUrlFor(bucket, key, until) {
const dir = cfg().directory;
try { return await createShareUrl(dir, bucket, key, until); }
catch (e) {
if (e.code !== 'pending') throw new HttpError(e.status || 502, e.message);
await admin.flush(cfg()); // send what is waiting, then try once more
try { return await createShareUrl(dir, bucket, key, until); }
catch (e2) { throw new HttpError(e2.status || 502, e2.code === 'pending' ? 'This file is still uploading to Sia. Try again in a minute.' : e2.message); }
}
}
route('POST', '/api/s3/:user/buckets/:bucket/sia-link', async (req, url, p) => {
if (hosted.isHosted()) throw new HttpError(409, 'Drives on Silent Mode share through Share… (navigate.st/s3).');
const { key, prefix, days = 365 } = await jsonBody(req);
const until = Math.floor(Date.now() / 1000) + Math.min(3650, Math.max(1, Number(days) || 365)) * 86400;
let body;
if (prefix) {
if (!prefix.endsWith('/')) throw new HttpError(400, 'a folder ends with /');
const s3 = await s3For(p.user);
const files = [];
let token;
do {
const page = await s3.listObjects(p.bucket, { prefix, token });
for (const o of page.objects) if (o.key !== prefix && o.size > 0) files.push(o.key);
token = page.truncated ? page.nextToken : null;
} while (token && files.length < 1000);
if (!files.length) throw new HttpError(400, 'This folder has no files to share (files in sub-folders are not included).');
const out = [];
for (const k of files) out.push({ name: k.slice(prefix.length), url: (await shareUrlFor(p.bucket, k, until)).url });
body = { name: prefix.slice(0, -1).split('/').pop(), folder: true, files: out };
} else {
if (!key || key.endsWith('/')) throw new HttpError(400, 'a file is required');
body = { name: key.split('/').pop(), url: (await shareUrlFor(p.bucket, key, until)).url };
}
const res = await fetch(SIA_LINKS_API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180_000) });
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`);
const list = readSiaLinks();
list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: prefix || null, until, created: Date.now(), files: body.files ? body.files.length : 1 });
writeSiaLinks(list);
return { id: out.id, url: out.url, until };
});
route('GET', '/api/sia-links', () => readSiaLinks().map(({ deleteToken, ...l }) => l));
route('DELETE', '/api/sia-links/:id', async (req, url, p) => {
const list = readSiaLinks();
const l = list.find((x) => x.id === p.id);
if (!l) throw new HttpError(404, 'no such link');
const res = await fetch(`${SIA_LINKS_API}/${encodeURIComponent(l.id)}`, { method: 'DELETE', headers: { 'x-delete-token': l.deleteToken }, signal: AbortSignal.timeout(30_000) });
if (!res.ok && res.status !== 404) throw new HttpError(502, `navigate.st answered ${res.status}`);
writeSiaLinks(list.filter((x) => x.id !== p.id));
return { ok: true };
});
// ---- free space on the Sia account ----------------------------------------
// From the indexer (signed with s3d's app key). Cached for a minute.
let spaceCache = { at: 0, value: null };
// ---- desktop updates: the newest Pithos desktop build on dl.silentmode.st ----
const RELEASES_URL = process.env.PITHOS_RELEASES_URL || 'https://dl.silentmode.st/releases-manifest.json';
let releaseCache = null;
route('GET', '/api/desktop-release', async (req, url) => {
if (releaseCache && Date.now() - releaseCache.at < 10 * 60_000 && url.searchParams.get('fresh') !== '1') return releaseCache.body;
const res = await fetch(`${RELEASES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store', signal: AbortSignal.timeout(15_000) }).catch((e) => { throw new HttpError(502, e.message); });
if (!res.ok) throw new HttpError(502, `the release list answered ${res.status}`);
// each build is listed per platform ("win-x64", "linux-x64", "mac-…"); only ours counts
const os = { win32: 'win', linux: 'linux', darwin: 'mac' }[process.platform];
const list = ((await res.json())?.releases || []).filter((e) => e.id === 'pithos-desktop' && /^\d+\.\d+\.\d+$/.test(e.version) && String(e.platform || '').startsWith(`${os}-`));
const latest = list.reduce((best, e) => (!best || cmpVersion(e.version, best.version) > 0 ? e : best), null);
const body = { latest: latest && { version: latest.version, date: latest.date || null, changes: String(latest.changes || '').slice(0, 600) } };
releaseCache = { at: Date.now(), body };
return body;
});
route('GET', '/api/space', async () => {
// On Silent Mode this call is forwarded: the server's s3d answers for its own folder.
if (Date.now() - spaceCache.at < 60_000 && spaceCache.value) return spaceCache.value;
try {
const s = await accountSpace(cfg().directory);
spaceCache = { at: Date.now(), value: s ? { available: true, ...s } : { available: false, reason: 'not connected' } };
} catch (e) {
spaceCache = { at: Date.now() - 45_000, value: { available: false, reason: e.message } };
}
return spaceCache.value;
});
// 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).
// HTML opened as a page. A ticket covers the folder the page sits in, so
// its relative CSS, images, scripts and links resolve; it lasts an hour from
// the last use (12 hours at most). See servePage for the sandbox.
const pages = new Map(); // id -> { user, bucket, prefix, exp, until }
route('POST', '/api/s3/:user/buckets/:bucket/page', async (req, url, p) => {
const { key } = await jsonBody(req);
if (typeof key !== 'string' || !/\.html?$/i.test(key)) throw new HttpError(400, 'only .html files open as pages');
const now = Date.now();
for (const [k, v] of pages) if (v.exp < now) pages.delete(k);
if (pages.size >= 200) throw new HttpError(429, 'too many pages open; close some and try again');
const cut = key.lastIndexOf('/') + 1;
const id = crypto.randomBytes(24).toString('base64url');
pages.set(id, { user: p.user, bucket: p.bucket, prefix: key.slice(0, cut), exp: now + 60 * 60_000, until: now + 12 * 3600_000 });
return { path: `/page/${id}/${key.slice(cut).split('/').map(encodeURIComponent).join('/')}` };
});
// The page and its neighbours. The sandbox CSP (no allow-same-origin) gives
// the page an opaque origin: its scripts run, but requests they make to this
// server are cross-site, so they carry no session cookie and cannot pass
// the x-pithos check. They can only read files the ticket covers.
// The page loads whatever it links to (it is the user's own site); the
// sandbox is the boundary, not a source list.
const PAGE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads; frame-ancestors 'self'";
async function servePage(req, res, id, rest) {
const t = pages.get(id);
const now = Date.now();
if (!t || t.exp < now) { res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8' }); return res.end('This page link has expired. Open the file from Pithos again.'); }
t.exp = Math.min(now + 60 * 60_000, t.until);
let segs;
try { segs = rest.split('/').map((x) => decodeURIComponent(x)); } catch { res.writeHead(400); return res.end('bad path'); }
if (!segs.length || segs[segs.length - 1] === '') segs[segs.length ? segs.length - 1 : 0] = 'index.html';
// An encoded slash would make one "segment" span folders (..%2F..).
if (segs.some((x) => !x || x === '.' || x === '..' || /[/\\]/.test(x))) { res.writeHead(400); return res.end('bad path'); }
const key = t.prefix + segs.join('/');
const up = await (await s3For(t.user)).getObject(t.bucket, key, { range: req.headers.range });
if (up.statusCode >= 300 && up.statusCode !== 304) {
up.resume?.();
res.writeHead(up.statusCode === 416 ? 416 : 404, { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': PAGE_CSP });
return res.end(up.statusCode === 416 ? 'range not satisfiable' : 'not found');
}
// The extension decides for web files: S3 clients often store HTML, CSS or
// scripts as octet-stream (or anything), and a page must load them as such.
const guessed = guessType(key);
const stored = up.headers['content-type'];
let type = guessed !== 'application/octet-stream' ? guessed : (stored || guessed);
if (/^text\/|javascript|json|xml|svg/i.test(type) && !/charset=/i.test(type)) type += '; charset=utf-8';
const headers = {
'content-type': type,
'content-security-policy': PAGE_CSP,
'x-content-type-options': 'nosniff',
'cache-control': 'no-store',
// The sandboxed page is cross-origin even to itself; its scripts may read
// the files its ticket covers (the ticket in the URL is the secret).
'access-control-allow-origin': '*',
};
for (const h of ['content-length', 'content-range', 'accept-ranges', 'last-modified', 'etag']) if (up.headers[h]) headers[h] = up.headers[h];
res.writeHead(up.statusCode, headers);
if (req.method === 'HEAD') { up.resume?.(); return res.end(); }
up.on('error', () => res.destroy());
up.pipe(res);
}
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;
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/<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) {
// 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('referrer-policy', 'no-referrer');
const page = /^\/page\/([A-Za-z0-9_-]{20,})\/(.*)$/.exec(url.pathname);
if (page && (req.method === 'GET' || req.method === 'HEAD')) {
// A page may frame its own folder's files; nothing else frames Pithos.
res.setHeader('x-frame-options', 'SAMEORIGIN');
try { return await servePage(req, res, page[1], page[2]); } catch (e) {
if (res.headersSent) return res.destroy();
res.writeHead(e instanceof S3Error && e.status < 500 ? e.status : 502, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(e.message);
}
}
res.setHeader('x-frame-options', 'DENY');
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 (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();
// Idle keep-alive sockets would hold close() for seconds; nobody is left to answer.
const closed = new Promise((r) => server.close(r));
server.closeAllConnections?.();
await closed;
},
};
}
// ---- 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);
});
}