New installs carry the same Pithos the extension channel serves, including drives on Silent Mode and the Sia account card.
437 lines
21 KiB
JavaScript
437 lines
21 KiB
JavaScript
// The client half of "On Silent Mode": signing in to pithos-hosted, the S3
|
|
// client that encrypts on the way out and decrypts on the way in, converting
|
|
// folders between private (encrypted) and readable when they are shared, and
|
|
// moving the s3d data folder between this computer and the server.
|
|
//
|
|
// Nothing secret leaves this process: the identity key signs a nonce, the
|
|
// encryption key never goes anywhere, and the Sia recovery phrase is not
|
|
// involved at all (only the connected s3d data folder moves).
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
import { S3Client, readBody, objectPath } from './s3.js';
|
|
import {
|
|
driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN,
|
|
} from './crypt.js';
|
|
|
|
// ---- secrets ---------------------------------------------------------------
|
|
|
|
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
|
|
// Hosts with a vault (Theseus) pass their own; everyone else gets a random
|
|
// secret in a file beside s3d.yml. Losing that file loses the files it
|
|
// encrypted, so the UI offers to export it.
|
|
export function fileSecrets(configFile) {
|
|
const file = path.join(path.dirname(configFile), 'pithos-hosted-secret.json');
|
|
let root = null;
|
|
const NOTE = 'Pithos hosted identity and encryption key. Anyone with this file can read your encrypted files; without it they are lost.';
|
|
function save(secret) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, secret: secret.toString('base64'), note: NOTE }, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
// A key is only ever created when there is no file at all. A file that
|
|
// cannot be read or parsed is an error, never a reason to make a new key:
|
|
// that would silently orphan everything the old one encrypted.
|
|
function load() {
|
|
if (root) return root;
|
|
let text;
|
|
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
|
|
if (e.code !== 'ENOENT') throw new Error(`cannot read your Pithos key file (${file}): ${e.message}`);
|
|
root = crypto.randomBytes(32);
|
|
save(root);
|
|
return root;
|
|
}
|
|
let b = null;
|
|
try { b = Buffer.from(JSON.parse(text).secret, 'base64'); } catch {}
|
|
if (!b || b.length !== 32) throw new Error(`your Pithos key file is damaged (${file}); restore it from your backup`);
|
|
root = b;
|
|
return root;
|
|
}
|
|
const derive = (label) => Buffer.from(crypto.hkdfSync('sha256', load(), Buffer.alloc(0), label, 32));
|
|
return {
|
|
kind: 'file',
|
|
file,
|
|
identitySeed: async () => derive('pithos/hosted-identity/v1'),
|
|
encryptionKey: async () => derive('pithos/hosted-encryption/v1'),
|
|
exportSecret: () => JSON.parse(fs.readFileSync(file, 'utf8')),
|
|
importSecret(obj) {
|
|
const b = Buffer.from(String(obj?.secret || ''), 'base64');
|
|
if (b.length !== 32) throw new Error('this is not a Pithos key file');
|
|
// The key being replaced may still be the only way to read some files.
|
|
if (fs.existsSync(file)) fs.copyFileSync(file, file.replace(/\.json$/, `.replaced-${Date.now()}.json`));
|
|
save(b);
|
|
root = b;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function identityFromSeed(seed) {
|
|
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, seed]), format: 'der', type: 'pkcs8' });
|
|
const pub = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(-32);
|
|
return { pubkey: pub.toString('hex'), sign: (msg) => crypto.sign(null, Buffer.from(msg, 'utf8'), key).toString('base64') };
|
|
}
|
|
|
|
// ---- gateway calls -------------------------------------------------------------
|
|
|
|
export class HostedError extends Error {
|
|
constructor(status, message, data) { super(message); this.status = status; this.data = data; }
|
|
}
|
|
|
|
export function gateway(url, token) {
|
|
const base = url.replace(/\/$/, '');
|
|
async function call(method, p, { body, headers = {}, raw, stream } = {}) {
|
|
const h = { ...headers };
|
|
if (token) h.authorization = `Bearer ${token}`;
|
|
let payload = body;
|
|
if (body !== undefined && !stream && !Buffer.isBuffer(body)) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
|
|
const res = await fetch(base + p, { method, headers: h, body: payload, duplex: stream ? 'half' : undefined });
|
|
if (raw) return res;
|
|
const text = await res.text();
|
|
let data = null;
|
|
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 300) }; }
|
|
if (!res.ok) throw new HostedError(res.status, data?.error || `HTTP ${res.status}`, data);
|
|
return data;
|
|
}
|
|
return { base, call, auth: token ? `Bearer ${token}` : null };
|
|
}
|
|
|
|
export async function signIn(url, secrets, signup) {
|
|
const g = gateway(url);
|
|
const id = identityFromSeed(await secrets.identitySeed());
|
|
const { nonce, message } = await g.call('GET', '/hosted/v1/challenge');
|
|
try {
|
|
return await g.call('POST', '/hosted/v1/session', { body: { pubkey: id.pubkey, nonce, signature: id.sign(message), signup } });
|
|
} catch (e) {
|
|
if (e.status === 404 && e.data?.needsSignup) return { needsSignup: true, ...e.data };
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ---- which files are readable ------------------------------------------------------
|
|
|
|
// Files anyone else can reach (public folders, shared keys, share links) must
|
|
// be stored readable; everything else is encrypted.
|
|
export function plainCovered(rules, bucket, key) {
|
|
const now = Date.now();
|
|
return rules.some((r) => r.bucket === bucket && (!r.expires || r.expires > now) && (
|
|
r.type === 'link' ? r.prefix === key : key.startsWith(r.prefix)
|
|
));
|
|
}
|
|
|
|
const MIME = {
|
|
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript',
|
|
json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip',
|
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon',
|
|
mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus',
|
|
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska',
|
|
};
|
|
export const guessType = (key) => MIME[String(key).split('.').pop().toLowerCase()] || 'application/octet-stream';
|
|
|
|
// ---- the encrypting S3 client ----------------------------------------------------------
|
|
|
|
// Same surface as S3Client for everything the control server's object routes
|
|
// use. Keys and sizes it returns are the plain ones. `encryptWrites` is false
|
|
// on this computer (local s3d already keeps files private) but reads still
|
|
// decrypt, so drives brought back from Silent Mode stay readable.
|
|
export class EncryptingS3Client {
|
|
constructor({ endpoint, accessKeyId, secretKey, master, rules = async () => [], encryptWrites = true }) {
|
|
this.inner = new S3Client({ endpoint, accessKeyId, secretKey });
|
|
this.master = master; // async () => Buffer(32)
|
|
this.rules = rules;
|
|
this.encryptWrites = encryptWrites;
|
|
this.keys = new Map();
|
|
}
|
|
|
|
async dk(bucket) {
|
|
if (!this.keys.has(bucket)) this.keys.set(bucket, driveKeys(await this.master(), bucket));
|
|
return this.keys.get(bucket);
|
|
}
|
|
|
|
async storeAsPlain(bucket, key) {
|
|
return !this.encryptWrites || plainCovered(await this.rules(), bucket, key);
|
|
}
|
|
|
|
listBuckets() { return this.inner.listBuckets(); }
|
|
createBucket(b) { return this.inner.createBucket(b); }
|
|
deleteBucket(b) { return this.inner.deleteBucket(b); }
|
|
presign(...a) { return this.inner.presign(...a); }
|
|
getPolicy(b) { return this.inner.getPolicy(b); }
|
|
putPolicy(b, p) { return this.inner.putPolicy(b, p); }
|
|
deletePolicy(b) { return this.inner.deletePolicy(b); }
|
|
|
|
// A folder can hold both forms (files from before hosting are plain), so a
|
|
// listing below the root asks for both prefixes and merges them.
|
|
async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) {
|
|
const dk = await this.dk(bucket);
|
|
const cut = prefix.lastIndexOf('/') + 1;
|
|
const folder = prefix.slice(0, cut);
|
|
const encFolder = folder ? encryptPath(dk, folder) : '';
|
|
const state = token ? JSON.parse(Buffer.from(token, 'base64url').toString('utf8')) : {};
|
|
const sources = folder ? [['e', encFolder], ['p', folder]] : [['p', '']];
|
|
const folders = new Map();
|
|
const objects = new Map();
|
|
const next = {};
|
|
for (const [name, pfx] of sources) {
|
|
// A source that has run out stays out on later pages.
|
|
if (state[name] === null) { next[name] = null; continue; }
|
|
const page = await this.inner.listObjects(bucket, { prefix: pfx, delimiter, token: state[name] || undefined, max });
|
|
next[name] = page.truncated ? page.nextToken : null;
|
|
for (const f of page.folders) {
|
|
const d = decryptPath(dk, f);
|
|
if (d.path.startsWith(prefix)) folders.set(d.path, { path: d.path, encrypted: d.encrypted });
|
|
}
|
|
for (const o of page.objects) {
|
|
const d = decryptPath(dk, o.key);
|
|
if (!d.path.startsWith(prefix)) continue;
|
|
const size = d.encrypted && !d.path.endsWith('/') ? plainSize(o.size) : o.size;
|
|
// Same name in both forms (re-uploaded after hosting): the encrypted one is newer.
|
|
if (objects.has(d.path) && !d.encrypted) continue;
|
|
objects.set(d.path, { key: d.path, size: size ?? o.size, modified: o.modified, etag: o.etag, encrypted: d.encrypted, storageKey: o.key });
|
|
}
|
|
}
|
|
const more = Object.values(next).some((v) => v);
|
|
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
return {
|
|
folders: [...folders.keys()].sort(order),
|
|
objects: [...objects.values()].sort((a, b) => order(a.key, b.key)),
|
|
truncated: more,
|
|
nextToken: more ? Buffer.from(JSON.stringify(next)).toString('base64url') : null,
|
|
};
|
|
}
|
|
|
|
async putObject(bucket, key, body, { contentType, contentLength } = {}) {
|
|
const dk = await this.dk(bucket);
|
|
if (await this.storeAsPlain(bucket, key)) {
|
|
const r = await this.inner.putObject(bucket, key, body, { contentType, contentLength });
|
|
// Reads prefer the encrypted form, so an older encrypted copy under the
|
|
// same name would hide this new version.
|
|
const ek = encryptPath(dk, key);
|
|
if (ek !== key) await this.inner.deleteObject(bucket, ek).catch(() => {});
|
|
return r;
|
|
}
|
|
const ek = encryptPath(dk, key);
|
|
if (key.endsWith('/')) return this.inner.putObject(bucket, ek, Buffer.alloc(0), { contentLength: 0 });
|
|
const len = Number(contentLength ?? (Buffer.isBuffer(body) ? body.length : NaN));
|
|
if (!Number.isFinite(len)) throw new Error('encrypted uploads need a Content-Length');
|
|
const { stream, size } = encryptBody(dk, key, body, len);
|
|
const r = await this.inner.putObject(bucket, ek, stream, { contentType: 'application/octet-stream', contentLength: size });
|
|
// An older readable copy under the same name would shadow nothing but
|
|
// would still be readable on the server: remove it.
|
|
if (ek !== key) await this.inner.deleteObject(bucket, key).catch(() => {});
|
|
return r;
|
|
}
|
|
|
|
// Resolves to a readable stream carrying statusCode and headers, like the
|
|
// http.IncomingMessage S3Client.getObject returns.
|
|
async getObject(bucket, key, { range } = {}) {
|
|
const dk = await this.dk(bucket);
|
|
const ek = encryptPath(dk, key);
|
|
const head = await this.inner.request('HEAD', objectPath(bucket, ek));
|
|
head.resume();
|
|
if (head.statusCode === 404 || head.statusCode === 403) return this.inner.getObject(bucket, key, { range });
|
|
if (head.statusCode >= 300) return this.inner.getObject(bucket, ek, { range });
|
|
const total = Number(head.headers['content-length']);
|
|
const r = cipherRange(total, range ? parseRange(range, plainSize(total) ?? 0) : null);
|
|
const base = { 'content-type': guessType(key), 'accept-ranges': 'bytes' };
|
|
if (head.headers.etag) base.etag = head.headers.etag;
|
|
if (head.headers['last-modified']) base['last-modified'] = head.headers['last-modified'];
|
|
if (r.unsatisfiable) return withStatus(Readable.from([]), 416, { ...base, 'content-range': `bytes */${r.plain}` });
|
|
const headerRes = await this.inner.getObject(bucket, ek, { range: `bytes=0-${r.first === 0 ? r.to : HEADER_LEN - 1}` });
|
|
if (headerRes.statusCode >= 300) return headerRes;
|
|
// The header arrives first; when the wanted chunks follow it directly the
|
|
// same response carries them too.
|
|
const iter = headerRes[Symbol.asyncIterator]();
|
|
let first = Buffer.alloc(0);
|
|
while (first.length < HEADER_LEN) {
|
|
const { value, done } = await iter.next();
|
|
if (done) throw new Error('encrypted file is shorter than its header');
|
|
first = Buffer.concat([first, value]);
|
|
}
|
|
const header = first.subarray(0, HEADER_LEN);
|
|
let body;
|
|
if (r.first === 0) {
|
|
const rest = first.subarray(HEADER_LEN);
|
|
body = Readable.from((async function* () { if (rest.length) yield rest; for (;;) { const { value, done } = await iter.next(); if (done) return; yield value; } })());
|
|
} else {
|
|
iter.return?.();
|
|
headerRes.destroy();
|
|
body = await this.inner.getObject(bucket, ek, { range: `bytes=${r.from}-${r.to}` });
|
|
if (body.statusCode >= 300) return body;
|
|
}
|
|
const out = decryptBody(dk, key, header, r.plain ? body : Readable.from([]), r);
|
|
const headers = { ...base, 'content-length': String(r.plain ? r.end - r.start + 1 : 0) };
|
|
let status = 200;
|
|
if (range) { status = 206; headers['content-range'] = `bytes ${r.start}-${r.end}/${r.plain}`; }
|
|
return withStatus(out, status, headers);
|
|
}
|
|
|
|
async deleteObject(bucket, key) {
|
|
const dk = await this.dk(bucket);
|
|
const ek = encryptPath(dk, key);
|
|
await this.inner.deleteObject(bucket, ek);
|
|
if (ek !== key) await this.inner.deleteObject(bucket, key);
|
|
}
|
|
|
|
async deletePrefix(bucket, prefix) {
|
|
let n = 0;
|
|
let token;
|
|
do {
|
|
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
|
|
for (const o of page.objects) { await this.inner.deleteObject(bucket, o.storageKey); n++; }
|
|
token = page.truncated ? page.nextToken : null;
|
|
} while (token);
|
|
return n;
|
|
}
|
|
|
|
// Re-stores every file under prefix in the wanted form: readable (shared)
|
|
// or encrypted (private). Rules must already say what the prefix should be.
|
|
async convert(bucket, prefix, { toPlain, onProgress = () => {}, skip = () => false }) {
|
|
const dk = await this.dk(bucket);
|
|
let done = 0;
|
|
let token;
|
|
const todo = [];
|
|
do {
|
|
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
|
|
for (const o of page.objects) if (o.encrypted === toPlain && !skip(o.key)) todo.push(o);
|
|
token = page.truncated ? page.nextToken : null;
|
|
} while (token);
|
|
for (const o of todo) {
|
|
onProgress({ done, total: todo.length, key: o.key });
|
|
const target = toPlain ? o.key : encryptPath(dk, o.key);
|
|
if (o.key.endsWith('/')) {
|
|
await this.inner.putObject(bucket, target, Buffer.alloc(0), { contentLength: 0 });
|
|
} else if (toPlain) {
|
|
const src = await this.getObject(bucket, o.key);
|
|
if (src.statusCode >= 300) throw new Error(`could not read ${o.key}: HTTP ${src.statusCode}`);
|
|
await this.inner.putObject(bucket, target, src, { contentType: guessType(o.key), contentLength: o.size });
|
|
} else {
|
|
const src = await this.inner.getObject(bucket, o.storageKey);
|
|
if (src.statusCode >= 300) throw new Error(`could not read ${o.key}: HTTP ${src.statusCode}`);
|
|
const { stream, size } = encryptBody(dk, o.key, src, o.size);
|
|
await this.inner.putObject(bucket, target, stream, { contentType: 'application/octet-stream', contentLength: size });
|
|
}
|
|
await this.inner.deleteObject(bucket, o.storageKey);
|
|
done++;
|
|
}
|
|
onProgress({ done, total: todo.length });
|
|
return { converted: done };
|
|
}
|
|
}
|
|
|
|
function withStatus(stream, statusCode, headers) {
|
|
stream.statusCode = statusCode;
|
|
stream.headers = headers;
|
|
return stream;
|
|
}
|
|
|
|
// ---- moving the data folder ----------------------------------------------------------------
|
|
|
|
// What travels: the database and objects still waiting for Sia. Logs, temp
|
|
// files, the local config, the key file and earlier accounts stay on this
|
|
// computer, and a path naming any of them is refused in either direction.
|
|
const SKIP_TOP = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-archive.json', 'pithos.json', 'tmp', 's3d.log']);
|
|
const skipTop = (name) => SKIP_TOP.has(name) || name.startsWith('previous-account-') || name.startsWith('pithos-hosted-secret') || name.endsWith('.log') || name.endsWith('.tmp');
|
|
|
|
// "uploads/ab.obj" style paths only: forward slashes, no empty, "." or ".."
|
|
// segments, no drive letters or backslashes, and nothing that is ours.
|
|
export function isTransferPath(rel) {
|
|
if (typeof rel !== 'string' || !rel || rel.length > 512) return false;
|
|
if (/[\\:\x00]/.test(rel) || rel.startsWith('/')) return false;
|
|
const segs = rel.split('/');
|
|
if (segs.some((x) => !x || x === '.' || x === '..')) return false;
|
|
return !skipTop(segs[0]);
|
|
}
|
|
|
|
// The file a transfer path names, proven to sit inside dir.
|
|
export function transferTarget(dir, rel) {
|
|
if (!isTransferPath(rel)) throw new Error(`refusing an unsafe file path: ${String(rel).slice(0, 80)}`);
|
|
const base = path.resolve(dir);
|
|
const target = path.resolve(base, ...rel.split('/'));
|
|
const r = path.relative(base, target);
|
|
if (!r || r.startsWith('..') || path.isAbsolute(r)) throw new Error(`refusing an unsafe file path: ${rel}`);
|
|
return target;
|
|
}
|
|
|
|
export function hashFile(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const h = crypto.createHash('sha256');
|
|
let size = 0;
|
|
fs.createReadStream(file)
|
|
.on('data', (c) => { h.update(c); size += c.length; })
|
|
.on('error', reject)
|
|
.on('end', () => resolve({ size, sha256: h.digest('hex') }));
|
|
});
|
|
}
|
|
|
|
// Hashes stream from disk: buffered objects can be gigabytes.
|
|
export async function dataFiles(dataDir) {
|
|
const out = [];
|
|
const rec = async (rel) => {
|
|
for (const e of fs.readdirSync(path.join(dataDir, rel), { withFileTypes: true })) {
|
|
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
if (!rel && skipTop(e.name)) continue;
|
|
if (e.isDirectory()) await rec(r);
|
|
else if (e.isFile() && isTransferPath(r)) out.push({ path: r, ...(await hashFile(path.join(dataDir, r))) });
|
|
}
|
|
};
|
|
await rec('');
|
|
return out;
|
|
}
|
|
|
|
export async function uploadData(g, dataDir, files, onProgress = () => {}) {
|
|
let sent = 0;
|
|
const total = files.reduce((n, f) => n + f.size, 0);
|
|
for (const f of files) {
|
|
onProgress({ phase: 'upload', file: f.path, sent, total });
|
|
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, {
|
|
method: 'PUT',
|
|
headers: { authorization: g.auth, 'content-length': String(f.size) },
|
|
body: fs.createReadStream(transferTarget(dataDir, f.path)),
|
|
duplex: 'half',
|
|
});
|
|
const r = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new HostedError(res.status, r.error || `upload of ${f.path} failed`);
|
|
if (r.sha256 !== f.sha256) throw new Error(`${f.path} changed while it was being sent`);
|
|
sent += f.size;
|
|
}
|
|
onProgress({ phase: 'upload', sent, total });
|
|
}
|
|
|
|
// The file list comes from the server, so every path is checked before
|
|
// anything is written, nothing existing is overwritten, and `written` lists
|
|
// exactly what this run created (the only files a failed move may remove).
|
|
export async function downloadData(g, dataDir, files, onProgress = () => {}, written = []) {
|
|
if (!Array.isArray(files) || !files.length) throw new Error('the server sent no files');
|
|
const targets = files.map((f) => {
|
|
const t = transferTarget(dataDir, f.path);
|
|
if (!/^[0-9a-f]{64}$/.test(String(f.sha256))) throw new Error(`no checksum for ${f.path}`);
|
|
if (fs.existsSync(t)) throw new Error(`${f.path} already exists on this computer`);
|
|
return t;
|
|
});
|
|
if (!files.some((f) => f.path === 's3d.db')) throw new Error('the server sent no s3d database');
|
|
let got = 0;
|
|
const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
|
|
for (let i = 0; i < files.length; i++) {
|
|
const f = files[i];
|
|
const target = targets[i];
|
|
onProgress({ phase: 'download', file: f.path, got, total });
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, { headers: { authorization: g.auth } });
|
|
if (!res.ok) throw new HostedError(res.status, (await res.json().catch(() => ({}))).error || `download of ${f.path} failed`);
|
|
const hash = crypto.createHash('sha256');
|
|
const src = Readable.fromWeb(res.body);
|
|
src.on('data', (c) => hash.update(c));
|
|
written.push(target);
|
|
await pipeline(src, fs.createWriteStream(target, { flags: 'wx' }));
|
|
if (hash.digest('hex') !== f.sha256) throw new Error(`${f.path} arrived damaged`);
|
|
got += Number(f.size) || 0;
|
|
}
|
|
onProgress({ phase: 'download', got, total });
|
|
}
|
|
|
|
export { readBody };
|