1118 lines
57 KiB
JavaScript
1118 lines
57 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';
|
|
import { stuckUploads } from './stuck.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; }
|
|
}
|
|
|
|
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
|
|
function readOwnVersion() {
|
|
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 LOADED_VERSION = readOwnVersion();
|
|
|
|
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
|
|
openBrowser = openExternal, // (url) => void: the user's default browser (Theseus opens openExternal in its own tab)
|
|
// ({ path }) => Promise<boolean>: the host asks the user, in its own UI, whether
|
|
// a browser that came to the open-in-Pithos address may be signed in.
|
|
confirmBrowserOpen,
|
|
handoffPort = HANDOFF_PORT, // fixed loopback port the pithos.sia website links to; null = off
|
|
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) });
|
|
// a flush s3d is in the middle of keeps any stop waiting (see Daemon.stop)
|
|
const flushNow = () => daemon.whileBusy(() => admin.flush(cfg()));
|
|
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();
|
|
// Sessions of a browser this host opened (or let in), not the host's own
|
|
// window: the UI leaves out the controls that only make sense in the host.
|
|
const externalSessions = new Set();
|
|
// One-time sign-in links handed to a browser: id -> expiry. Unlike the
|
|
// host's ?t= link they work once, for a minute.
|
|
const signins = new Map();
|
|
function signInUrl(route = '') {
|
|
for (const [k, exp] of signins) if (exp < Date.now()) signins.delete(k);
|
|
const id = crypto.randomBytes(24).toString('base64url');
|
|
signins.set(id, Date.now() + 60_000);
|
|
const frag = /^#\/[A-Za-z0-9._~%/=-]{0,300}$/.test(route) ? route : '';
|
|
return `${baseUrl}?s=${id}${frag}`;
|
|
}
|
|
// 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;
|
|
// The version of the code running (read when this module loaded) and the one
|
|
// on disk now. Theseus can swap an add-on's files in place, but Node keeps
|
|
// the modules it already loaded, so they differ until Theseus restarts.
|
|
const pithosVersion = LOADED_VERSION;
|
|
const installedVersion = readOwnVersion();
|
|
|
|
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,
|
|
// differs from pithos after an in-place update until the host restarts
|
|
installed: readOwnVersion() || installedVersion,
|
|
// 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 () => {
|
|
try { await flushNow(); } catch (e) {
|
|
if (/failed to open upload|cannot find the file|no such file/i.test(e.message)) {
|
|
throw new HttpError(409, 'Some files waiting for Sia lost their copy on this computer, so the upload stopped. Pithos lists them on the Drives page: upload them again or remove them.');
|
|
}
|
|
throw e;
|
|
}
|
|
return { ok: true };
|
|
});
|
|
|
|
// Waiting files whose buffered copy is gone (see core/stuck.js).
|
|
route('GET', '/api/uploads/stuck', async () => {
|
|
if (hosted.isHosted()) return { items: [] };
|
|
if (!daemon.quiet()) return { items: [], busy: true };
|
|
return { items: await stuckUploads(cfg().directory) };
|
|
});
|
|
route('POST', '/api/uploads/stuck/remove', async (req) => {
|
|
const { bucket, key } = await jsonBody(req);
|
|
const item = (await stuckUploads(cfg().directory)).find((i) => i.bucket === bucket && i.key === key);
|
|
if (!item) throw new HttpError(409, 'This file is no longer stuck (it may have been uploaded again).');
|
|
if (!item.user) throw new HttpError(409, 'No S3 user owns this drive.');
|
|
await (await s3For(item.user)).deleteObject(bucket, key);
|
|
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: () => flushNow(),
|
|
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 flushNow(); // 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;
|
|
// prefix "" (with no key) is the drive's top level
|
|
if (prefix != null && !key) {
|
|
if (prefix && !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 ? prefix.slice(0, -1).split('/').pop() : p.bucket, 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: key ? null : (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;
|
|
});
|
|
|
|
// "Open in browser": the same Pithos in the user's default browser, signed
|
|
// in with a one-time link, at the page the user is on.
|
|
route('POST', '/api/open-in-browser', async (req) => {
|
|
if (!openBrowser) throw new HttpError(400, 'this Pithos cannot open a browser');
|
|
const { route: r = '' } = await jsonBody(req);
|
|
openBrowser(signInUrl(String(r)));
|
|
return { ok: true };
|
|
});
|
|
|
|
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, { external = false } = {}) {
|
|
const id = crypto.randomBytes(24).toString('base64url');
|
|
sessions.add(id);
|
|
if (external) externalSessions.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();
|
|
}
|
|
const once = url.searchParams.get('s');
|
|
if (once && req.method === 'GET' && !url.pathname.startsWith('/api/')) {
|
|
const exp = signins.get(once);
|
|
signins.delete(once);
|
|
if (exp && exp > Date.now()) issueSession(res, { external: true });
|
|
res.writeHead(302, { location: url.pathname }); // the #route stays with the browser
|
|
return res.end();
|
|
}
|
|
|
|
if (url.pathname === '/api/session') {
|
|
if (req.method === 'GET') { const c = cookieSession(req); return sendJson(res, 200, { authenticated: !!c, external: !!c && externalSessions.has(c), passwordLogin: !!password, host: hostName, canOpenBrowser: !!openBrowser }); }
|
|
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}/`;
|
|
const handoff = handoffPort && confirmBrowserOpen ? startHandoff(handoffPort, confirmBrowserOpen, signInUrl, (m) => daemon.pushLog('sys', m)) : null;
|
|
|
|
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();
|
|
handoff?.close();
|
|
await daemon.stop({ waitMs: 20_000 }); // the host is quitting: a short wait, then a clean 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;
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---- open from the pithos.sia website ---------------------------------------
|
|
// The website links to http://127.0.0.1:47621/open?path=/<user>/<drive>. Any
|
|
// page or local program could follow that link, so nobody is signed in until
|
|
// the user says yes in the host's own window; the page that sent the browser
|
|
// here learns nothing either way. One question at a time.
|
|
export const HANDOFF_PORT = 47621;
|
|
|
|
export function handoffRoute(p) {
|
|
const segs = String(p || '').split('/').filter(Boolean).slice(0, 12);
|
|
if (!segs.length || segs.some((x) => !/^[A-Za-z0-9._~%=-]{1,120}$/.test(x))) return '#/overview';
|
|
const pages = ['overview', 'users', 'buckets', 'account', 'settings', 'logs'];
|
|
if (segs[0] === 'drives') return '#/buckets';
|
|
if (segs[0] === 'app') return segs.length > 1 ? handoffRoute('/' + segs.slice(1).join('/')) : '#/overview';
|
|
if (pages.includes(segs[0])) return `#/${segs.join('/')}`;
|
|
return `#/u/${segs.join('/')}`; // /<user>/<drive>/<folder>
|
|
}
|
|
|
|
function startHandoff(port, confirm, signInUrl, log) {
|
|
const page = (res, status, title, text) => {
|
|
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" });
|
|
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title><body style="font:16px system-ui;background:#0f1214;color:#e6ebe8;display:grid;place-items:center;min-height:90vh;margin:0"><div style="max-width:460px;padding:24px"><h1 style="font-size:20px">${title}</h1><p style="color:#9aa5a0">${text}</p></div>`);
|
|
};
|
|
let pending = false;
|
|
const srv = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
// DNS rebinding: only the loopback names, never a hostname that resolves here
|
|
if (!new RegExp(`^(127\\.0\\.0\\.1|localhost|\\[::1\\]):${port}$`).test(req.headers.host || '')) return page(res, 403, 'Not allowed', 'Use http://127.0.0.1 to open Pithos.');
|
|
if (req.method !== 'GET' || url.pathname !== '/open') return page(res, 404, 'Pithos', 'Nothing here. Open Pithos from the pithos.sia website.');
|
|
if (pending) return page(res, 429, 'Answer Pithos first', 'Pithos is already asking whether to open in a browser. Answer it there, then try again.');
|
|
pending = true;
|
|
try {
|
|
let timer;
|
|
const ok = await Promise.race([
|
|
Promise.resolve(confirm({ path: url.searchParams.get('path') || '/' })).catch(() => false),
|
|
new Promise((r) => { timer = setTimeout(() => r(false), 120_000); }),
|
|
]).finally(() => clearTimeout(timer));
|
|
if (!ok) return page(res, 403, 'Not opened', 'Pithos was not opened in this browser. If you meant to, go back and press Open my Pithos again, then choose Open in Pithos.');
|
|
res.writeHead(302, { location: signInUrl(handoffRoute(url.searchParams.get('path'))), 'cache-control': 'no-store' });
|
|
res.end();
|
|
} finally { pending = false; }
|
|
});
|
|
srv.on('error', (e) => log(e.code === 'EADDRINUSE' ? `another Pithos already answers the website on port ${port}` : `website link: ${e.message}`));
|
|
srv.listen(port, '127.0.0.1');
|
|
return { close: () => srv.close() };
|
|
}
|
|
|
|
// ---- 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);
|
|
});
|
|
}
|