pithos/core/hosted-routes.js

397 lines
18 KiB
JavaScript
Raw Normal View History

// "On Silent Mode" inside the control server. In hosted mode the UI talks to
// this server exactly as before; this module answers by:
// - forwarding daemon, users, keys, stats and status calls to the gateway,
// - giving the object routes an S3 client that encrypts and decrypts here,
// - turning "Access" and "Share link" into gateway rules (and converting the
// files they cover to readable copies, since visitors have no key),
// - moving the s3d data folder to the server and back.
import fs from 'node:fs';
import path from 'node:path';
import * as admin from './admin.js';
import * as accounts from './accounts.js';
import { readPrefs, writePrefs } from './prefs.js';
import { ensureConfig } from './config.js';
import {
fileSecrets, signIn, gateway, HostedError, EncryptingS3Client, plainCovered,
dataFiles, uploadData, downloadData,
} from './hosted.js';
export const DEFAULT_HOSTED_URL = 'https://s3.silentmode.st';
// Calls forwarded as they are. The gateway allows the same set.
const FORWARD = [
['GET', /^\/api\/status$/], ['GET', /^\/api\/logs$/], ['GET', /^\/api\/stats$/],
['POST', /^\/api\/flush$/], ['GET', /^\/api\/autoflush$/],
['POST', /^\/api\/daemon\/(start|restart)$/],
['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/],
['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/],
['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/],
];
// Things that act on this computer's s3d and make no sense while drives live
// on the server.
const LOCAL_ONLY = [
[/^\/api\/binary\/install$/, 'There is nothing to install while your drives are on Silent Mode.'],
[/^\/api\/login/, 'Your drives are on Silent Mode, already connected to your Sia account.'],
[/^\/api\/account\/(switch|restore)$/, 'Move your drives back to this computer before switching Sia accounts.'],
[/^\/api\/daemon\/stop$/, 'On Silent Mode, s3d stops by itself when nobody uses it.'],
];
export function installHosted(ctx) {
const { route, configFile, cfg, daemon, broadcast, registration, resetRegistration, HttpError } = ctx;
const secrets = ctx.secrets || fileSecrets(configFile);
const prefs = () => readPrefs(configFile);
const hp = () => prefs().hosted || null;
const isHosted = () => prefs().mode === 'hosted' && !!hp()?.token;
const gw = () => {
const h = hp();
if (!h?.token) throw new HttpError(401, 'sign in to Silent Mode first');
return gateway(h.url, h.token);
};
const wrap = async (fn) => {
try { return await fn(); } catch (e) {
if (e instanceof HostedError) {
if (e.status === 401) { writePrefs(configFile, { hosted: { ...hp(), token: null } }); }
throw new HttpError(e.status, e.message);
}
throw e;
}
};
// ---- rules, cached briefly ----
let rulesCache = null;
async function rules() {
if (!isHosted()) return [];
if (rulesCache && Date.now() - rulesCache.at < 30_000) return rulesCache.list;
const list = await wrap(() => gw().call('GET', '/hosted/v1/rules'));
rulesCache = { at: Date.now(), list };
return list;
}
const dropRules = () => { rulesCache = null; };
// ---- the S3 client for object routes ----
const keyCache = new Map();
async function hostedKeys(user) {
const c = keyCache.get(user);
if (c && Date.now() - c.at < 60_000) return c.keys;
const keys = await wrap(() => gw().call('GET', `/api/keys?user=${encodeURIComponent(user)}`));
keyCache.set(user, { at: Date.now(), keys });
return keys;
}
async function s3For(user, localKeys) {
if (isHosted()) {
const keys = await hostedKeys(user);
if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`);
return new EncryptingS3Client({
endpoint: hp().url, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey,
master: () => secrets.encryptionKey(), rules,
});
}
// Back on this computer, files encrypted while hosted still decrypt.
if (prefs().encryptedData && localKeys?.length) {
return new EncryptingS3Client({
endpoint: `http://${cfg().apiAddress}`, accessKeyId: localKeys[0].accessKeyId, secretKey: localKeys[0].secretKey,
master: () => secrets.encryptionKey(), encryptWrites: false,
});
}
return null;
}
// ---- status, logs and events from the server ----
let remote = null; // last /api/status from the gateway
async function hostedStatus(local) {
const h = hp();
try {
remote = await wrap(() => gw().call('GET', '/api/status'));
return { ...remote, host: local.host, mode: 'hosted', login: local.login, hosted: { ...remote.hosted, url: h.url } };
} catch (e) {
return { ...local, mode: 'hosted', daemon: { state: 'stopped', error: e.message }, hosted: { url: h.url, error: e.message }, registration: { registered: true } };
}
}
let lastSeq = 0;
let lastDaemon = '';
async function pollRemote() {
if (!isHosted() || !ctx.hasSubscribers()) return;
try {
const st = await gw().call('GET', '/api/status');
remote = st;
const d = JSON.stringify(st.daemon);
if (d !== lastDaemon) { lastDaemon = d; broadcast('status', st.daemon); }
const logs = await gw().call('GET', `/api/logs?since=${lastSeq}`);
for (const l of logs || []) { lastSeq = Math.max(lastSeq, l.seq); broadcast('log', l); }
} catch { /* the next poll tries again */ }
}
const poller = setInterval(() => { pollRemote(); }, 5000);
poller.unref?.();
// ---- intercept: runs before the local routes in hosted mode ----
async function intercept(req, res, url, send) {
if (!isHosted()) return false;
const p = url.pathname;
for (const [re, msg] of LOCAL_ONLY) if (re.test(p)) throw new HttpError(409, msg);
if (p === '/api/config' && req.method === 'PUT') throw new HttpError(409, 'Server settings are managed by Silent Mode while your drives are there.');
if (p === '/api/status') return false; // answered by the local route, which asks us
if (!FORWARD.some(([m, re]) => m === req.method && re.test(p))) return false;
const body = req.method === 'GET' ? undefined : await readJson(req);
const out = await wrap(() => gw().call(req.method, p + url.search, { body }));
if (/^\/api\/(users|keys)/.test(p) && req.method !== 'GET') keyCache.clear();
send(out);
return true;
}
// ---- routes ----
route('GET', '/api/hosted', async () => {
const h = hp();
const out = { mode: isHosted() ? 'hosted' : 'local', url: h?.url || DEFAULT_HOSTED_URL, id: h?.id || null, signedIn: !!h?.token, secrets: secrets.kind || 'vault' };
if (h?.token) {
try { out.me = await gw().call('GET', '/hosted/v1/me'); } catch (e) { out.error = e.message; if (e.status === 401) out.signedIn = false; }
}
if (!out.signedIn) {
try { out.info = await gateway(out.url).call('GET', '/hosted/v1/info'); } catch (e) { out.error = `Silent Mode is not reachable: ${e.message}`; }
}
return out;
});
route('POST', '/api/hosted/signin', async (req) => {
const { url = DEFAULT_HOSTED_URL, signup } = await readJson(req);
if (!/^https:\/\/[^\s/]+$|^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(url.replace(/\/$/, ''))) throw new HttpError(400, 'the server address must be https');
const r = await wrap(() => signIn(url.replace(/\/$/, ''), secrets, signup));
if (r.needsSignup) return r;
writePrefs(configFile, { hosted: { url: url.replace(/\/$/, ''), id: r.id, token: r.token } });
return { ok: true, id: r.id, state: r.state };
});
route('POST', '/api/hosted/signout', async () => {
const h = hp();
if (h?.token) await gw().call('DELETE', '/hosted/v1/session').catch(() => {});
writePrefs(configFile, { hosted: h ? { ...h, token: null } : null, mode: 'local' });
dropRules();
keyCache.clear();
return { ok: true };
});
// Use drives already on Silent Mode from this computer (a second device).
route('POST', '/api/hosted/mode', async (req) => {
const { mode } = await readJson(req);
if (mode === 'local') { writePrefs(configFile, { mode: 'local' }); return { mode: 'local' }; }
if (mode !== 'hosted') throw new HttpError(400, 'mode is local or hosted');
const me = await wrap(() => gw().call('GET', '/hosted/v1/me'));
if (me.state !== 'active') throw new HttpError(409, 'Silent Mode has no drives for you yet. Move them there first.');
writePrefs(configFile, { mode: 'hosted', encryptedData: true });
return { mode: 'hosted' };
});
// Local -> Silent Mode. Nothing is re-uploaded to Sia: s3d's database and
// any objects still waiting for Sia move, then s3d runs on the server.
route('POST', '/api/hosted/move-up', async (req) => {
const { force } = await readJson(req);
const g = gw();
const progress = (p) => broadcast('move', { direction: 'up', ...p });
const me = await wrap(() => g.call('GET', '/hosted/v1/me'));
if (me.state !== 'empty') throw new HttpError(409, 'Silent Mode already holds drives for you. Use them from here, or move them back first.');
const c = cfg();
const reg = await registration();
if (!reg?.registered) throw new HttpError(409, 'Connect this computer to your Sia account first; then move the connection to Silent Mode.');
const wasRunning = !!daemon.child;
if (wasRunning) {
progress({ phase: 'flush' });
try { await admin.flush(c); } catch (e) {
if (!force) throw new HttpError(409, `Some uploads could not reach Sia yet (${e.message}). Try again later, or move anyway: they will finish on the server.`);
}
}
progress({ phase: 'stop' });
await daemon.stop();
let committed = false;
try {
const files = dataFiles(c.directory);
if (!files.some((f) => f.path === 's3d.db')) throw new HttpError(409, 'there is no s3d database to move');
await uploadData(g, c.directory, files, progress);
progress({ phase: 'start' });
const r = await wrap(() => g.call('POST', '/hosted/v1/data/commit', { body: { files } }));
committed = true;
if (!r.registered) throw new HttpError(502, 'the server could not use the moved connection');
// Keep the local copy, set aside like an account switch, never deleted.
const meta = { indexerUrl: reg.indexerUrl, users: r.users, movedTo: hp().url };
accounts.archiveCurrent(c.directory, meta);
resetRegistration();
writePrefs(configFile, { mode: 'hosted', encryptedData: true, autostart: false });
keyCache.clear();
dropRules();
progress({ phase: 'done' });
return { ok: true, ...r };
} catch (e) {
if (!committed) await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
if (wasRunning && !committed) { try { ensureConfig(configFile); daemon.start(); } catch {} }
progress({ phase: 'failed', error: e.message });
throw e;
}
});
// Silent Mode -> local. The server flushes and stops its s3d, the folder
// comes down, the local s3d starts on it, then the server copy is deleted.
route('POST', '/api/hosted/move-down', async (req) => {
const { force } = await readJson(req);
const g = gw();
const progress = (p) => broadcast('move', { direction: 'down', ...p });
const c = cfg();
if (daemon.child) await daemon.stop();
let archived = null;
if (fs.existsSync(path.join(c.directory, 's3d.db'))) {
archived = accounts.archiveCurrent(c.directory, { note: 'set aside to bring drives back from Silent Mode' }).name;
}
progress({ phase: 'flush' });
const man = await wrap(() => g.call('POST', `/hosted/v1/data/checkout${force ? '?force=1' : ''}`));
try {
await downloadData(g, c.directory, man.files, progress);
progress({ phase: 'start' });
ensureConfig(configFile);
writePrefs(configFile, { mode: 'local', autostart: true, encryptedData: true });
resetRegistration();
daemon.start();
const until = Date.now() + 30_000;
while (daemon.state === 'starting' && Date.now() < until) await new Promise((r) => setTimeout(r, 200));
if (daemon.state !== 'running') throw new Error('s3d did not start on the moved folder');
const reg = await registration();
if (!reg?.registered) throw new Error('the moved folder has no Sia connection');
await wrap(() => g.call('POST', '/hosted/v1/data/release'));
keyCache.clear();
dropRules();
progress({ phase: 'done' });
return { ok: true };
} catch (e) {
await daemon.stop().catch(() => {});
for (const f of man.files) fs.rmSync(path.join(c.directory, ...f.path.split('/')), { force: true });
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
writePrefs(configFile, { mode: 'hosted' });
await g.call('POST', '/hosted/v1/data/cancel-checkout').catch(() => {});
progress({ phase: 'failed', error: e.message });
throw new HttpError(502, `Moving back failed and nothing changed: ${e.message}`);
}
});
// Rules: public folders, shared keys, links. Files they cover become
// readable copies; removing a rule encrypts them again.
route('GET', '/api/hosted/rules', async () => rules());
route('POST', '/api/hosted/rules', async (req) => {
const body = await readJson(req);
const client = await s3For(body.user);
if (!client) throw new HttpError(409, 'not on Silent Mode');
const rule = await wrap(() => gw().call('POST', '/hosted/v1/rules', { body }));
dropRules();
await client.convert(rule.bucket, rule.prefix, { toPlain: true, onProgress: (p) => broadcast('convert', { rule: rule.id, ...p }) });
return rule;
});
route('DELETE', '/api/hosted/rules', async (req, url) => {
const id = url.searchParams.get('id');
const user = url.searchParams.get('user');
const rule = (await rules()).find((r) => r.id === id);
if (!rule) throw new HttpError(404, 'no such rule');
await wrap(() => gw().call('DELETE', `/hosted/v1/rules?id=${encodeURIComponent(id)}`));
dropRules();
const remaining = await rules();
const client = await s3For(user);
if (client) {
await client.convert(rule.bucket, rule.prefix, {
toPlain: false,
skip: (key) => plainCovered(remaining, rule.bucket, key),
onProgress: (p) => broadcast('convert', { rule: rule.id, ...p }),
});
}
return { ok: true };
});
// Files from before hosting (or from another S3 client) are stored as they
// came. This encrypts every one that nothing shares.
route('POST', '/api/hosted/encrypt', async (req) => {
const { user, bucket } = await readJson(req);
const client = await s3For(user);
if (!isHosted() || !client) throw new HttpError(409, 'not on Silent Mode');
const current = await rules();
return client.convert(bucket, '', {
toPlain: false,
skip: (key) => plainCovered(current, bucket, key),
onProgress: (p) => broadcast('convert', p),
});
});
// The whole-drive "Access" dialog, answered with a public rule on "".
async function drivePolicy(bucket) {
const r = (await rules()).find((x) => x.type === 'public' && x.bucket === bucket && x.prefix === '');
return { policy: null, public: r ? r.rights : 'private', hosted: true };
}
async function setDrivePolicy(user, bucket, mode) {
const current = (await rules()).filter((x) => x.type === 'public' && x.bucket === bucket && x.prefix === '');
const client = await s3For(user);
for (const r of current) await wrap(() => gw().call('DELETE', `/hosted/v1/rules?id=${encodeURIComponent(r.id)}`));
dropRules();
if (mode === 'private') {
const remaining = await rules();
await client.convert(bucket, '', { toPlain: false, skip: (key) => plainCovered(remaining, bucket, key), onProgress: (p) => broadcast('convert', p) });
return;
}
await wrap(() => gw().call('POST', '/hosted/v1/rules', { body: { type: 'public', bucket, prefix: '', rights: mode } }));
dropRules();
await client.convert(bucket, '', { toPlain: true, onProgress: (p) => broadcast('convert', p) });
}
// "Share link" for one file: a link rule, and the file stored readable.
async function shareLink(user, bucket, key, seconds) {
const client = await s3For(user);
const expires = Date.now() + Math.min(Math.max(60, seconds | 0), 30 * 86400) * 1000;
const rule = await wrap(() => gw().call('POST', '/hosted/v1/rules', { body: { type: 'link', bucket, prefix: key, expires } }));
dropRules();
await client.convert(bucket, key, { toPlain: true, skip: (k) => k !== key });
const links = (prefs().hostedLinks || []).filter((l) => l.expires > Date.now());
links.push({ user, bucket, key, expires });
writePrefs(configFile, { hostedLinks: links });
return `${hp().url}/l/${rule.token}`;
}
// Files shared by a link that has run out go back to being encrypted.
async function sweepLinks() {
if (!isHosted()) return;
const all = prefs().hostedLinks || [];
const expired = all.filter((l) => l.expires <= Date.now());
if (!expired.length) return;
dropRules();
const remaining = await rules();
for (const l of expired) {
if (plainCovered(remaining, l.bucket, l.key)) continue;
const client = await s3For(l.user).catch(() => null);
await client?.convert(l.bucket, l.key, { toPlain: false, skip: (k) => k !== l.key }).catch(() => {});
}
writePrefs(configFile, { hostedLinks: all.filter((l) => l.expires > Date.now()) });
}
const sweeper = setInterval(() => { sweepLinks().catch(() => {}); }, 10 * 60_000);
sweeper.unref?.();
// Key file export/import for hosts without a vault.
route('GET', '/api/hosted/secret', () => {
if (!secrets.exportSecret) throw new HttpError(400, 'your keys come from the Theseus vault; back up the vault instead');
return secrets.exportSecret();
});
route('POST', '/api/hosted/secret', async (req) => {
if (!secrets.importSecret) throw new HttpError(400, 'your keys come from the Theseus vault');
if (isHosted() || hp()?.token) throw new HttpError(409, 'sign out of Silent Mode before replacing your key');
secrets.importSecret(await readJson(req));
return { ok: true };
});
return {
isHosted, intercept, s3For, hostedStatus, drivePolicy, setDrivePolicy, shareLink,
remoteDaemon: () => remote?.daemon || { state: 'stopped' },
close() { clearInterval(poller); clearInterval(sweeper); },
};
}
async function readJson(req) {
const parts = [];
for await (const c of req) parts.push(c);
const text = Buffer.concat(parts).toString('utf8');
if (!text) return {};
try { return JSON.parse(text); } catch { return {}; }
}