Renaming refused on hosted drives because encrypted names and contents are bound to their path. The encrypting client now renames itself: readable files keep s3d's server-side copy, the rest are decrypted and encrypted again for their new place. A drive with shared folders or links is not renamed, since its rules belong to the old name. Free space is forwarded to the server, whose s3d answers for its own folder, instead of reporting nothing. space.js now builds on sia-account.js, so BLAKE2b and the indexer request signing exist once.
461 lines
22 KiB
JavaScript
461 lines
22 KiB
JavaScript
// "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$/],
|
|
['GET', /^\/api\/space$/],
|
|
];
|
|
// 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 { const warn = await settleMoves(); if (warn) out.warning = warn; } catch {}
|
|
out.mode = isHosted() ? 'hosted' : 'local';
|
|
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' };
|
|
});
|
|
|
|
// Asks the server who holds the drives, a few times. null if it cannot tell.
|
|
async function serverState(g) {
|
|
for (let i = 0; i < 3; i++) {
|
|
try { return (await g.call('GET', '/hosted/v1/me')).state; } catch { await new Promise((r) => setTimeout(r, 1000 * (i + 1))); }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The server has the drives: set the local copy aside so it can never run
|
|
// next to the server's (that would fork the account).
|
|
function adoptHosted(reg, users) {
|
|
const c = cfg();
|
|
if (fs.existsSync(path.join(c.directory, 's3d.db'))) {
|
|
accounts.archiveCurrent(c.directory, { indexerUrl: reg?.indexerUrl || null, users: users || [], movedTo: hp().url });
|
|
}
|
|
resetRegistration();
|
|
writePrefs(configFile, { mode: 'hosted', encryptedData: true, autostart: false, moveUncertain: null });
|
|
keyCache.clear();
|
|
dropRules();
|
|
}
|
|
|
|
// Finishes a move whose last answer was lost (see move-up and move-down).
|
|
async function settleMoves() {
|
|
const p = prefs();
|
|
if (!p.moveUncertain && !p.pendingRelease) return null;
|
|
const g = gw();
|
|
const state = await serverState(g);
|
|
if (state == null) return 'Silent Mode is not reachable, so an earlier move is not finished yet.';
|
|
if (p.moveUncertain) {
|
|
if (state === 'active') adoptHosted(p.moveUncertain.reg, p.moveUncertain.users);
|
|
else writePrefs(configFile, { moveUncertain: null }); // it never arrived; the local copy is the account
|
|
}
|
|
if (p.pendingRelease) {
|
|
if (state === 'moving-out') await g.call('POST', '/hosted/v1/data/release').catch(() => {});
|
|
if ((await serverState(g)) !== 'moving-out') writePrefs(configFile, { pendingRelease: null });
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 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 });
|
|
if (prefs().moveUncertain) throw new HttpError(409, 'An earlier move is not settled yet; open Settings again in a minute.');
|
|
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();
|
|
const restartLocal = () => { if (wasRunning) { try { ensureConfig(configFile); daemon.start(); } catch {} } };
|
|
let files;
|
|
try {
|
|
files = await 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);
|
|
} catch (e) {
|
|
// Nothing was committed: the server discards what arrived, s3d runs here again.
|
|
await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
|
|
restartLocal();
|
|
progress({ phase: 'failed', error: e.message });
|
|
throw e;
|
|
}
|
|
progress({ phase: 'start' });
|
|
let r = null;
|
|
let commitError = null;
|
|
try { r = await wrap(() => g.call('POST', '/hosted/v1/data/commit', { body: { files } })); } catch (e) { commitError = e; }
|
|
// From here an answer can be lost in either direction, so the server's
|
|
// state decides which copy is the account.
|
|
const state = r ? 'active' : await serverState(g);
|
|
if (state === 'active') {
|
|
adoptHosted(reg, r?.users);
|
|
progress({ phase: 'done' });
|
|
return { ok: true, registered: true, users: r?.users || [] };
|
|
}
|
|
if (state === 'empty') {
|
|
restartLocal();
|
|
progress({ phase: 'failed', error: commitError?.message });
|
|
throw commitError || new HttpError(502, 'the server did not take the drives');
|
|
}
|
|
// Unknown: keep s3d stopped here until the server can be asked again.
|
|
writePrefs(configFile, { moveUncertain: { at: Date.now(), reg: { indexerUrl: reg.indexerUrl }, users: [] } });
|
|
progress({ phase: 'failed', error: 'no answer from Silent Mode' });
|
|
throw new HttpError(502, 'Silent Mode stopped answering during the move. s3d stays stopped here until Pithos can check which side has your drives; open Settings again in a minute.');
|
|
});
|
|
|
|
// Silent Mode -> local. The server flushes and stops its s3d, the folder
|
|
// comes down, the local s3d starts on it, and only then is the server copy
|
|
// released. Once release is sent the local copy is never removed.
|
|
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' });
|
|
let man;
|
|
try { man = await wrap(() => g.call('POST', `/hosted/v1/data/checkout${force ? '?force=1' : ''}`)); } catch (e) {
|
|
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
|
|
throw e;
|
|
}
|
|
const written = [];
|
|
try {
|
|
await downloadData(g, c.directory, man.files, progress, written);
|
|
progress({ phase: 'start' });
|
|
ensureConfig(configFile);
|
|
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');
|
|
} catch (e) {
|
|
// Only what this move wrote is removed; the server keeps its copy.
|
|
await daemon.stop().catch(() => {});
|
|
for (const f of written) fs.rmSync(f, { 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}`);
|
|
}
|
|
// The drives now run here. Whatever happens to the release, this copy stays.
|
|
writePrefs(configFile, { mode: 'local', autostart: true, encryptedData: true, pendingRelease: true });
|
|
keyCache.clear();
|
|
dropRules();
|
|
try {
|
|
await g.call('POST', '/hosted/v1/data/release');
|
|
writePrefs(configFile, { pendingRelease: null });
|
|
} catch { /* settleMoves() retries; the server copy stays parked meanwhile */ }
|
|
progress({ phase: 'done' });
|
|
return { ok: true };
|
|
});
|
|
|
|
// 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,
|
|
userNames: async () => (await wrap(() => gw().call('GET', '/api/users'))).map((u) => u.name),
|
|
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 {}; }
|
|
}
|