New installs carry the same Pithos the extension channel serves, including drives on Silent Mode and the Sia account card.
460 lines
22 KiB
JavaScript
460 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$/],
|
|
];
|
|
// 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 {}; }
|
|
}
|