Pithos UI: choose where your drives live
Settings gains a sign-in, sign-up and move card for Silent Mode; Overview, the status pill, Access and Share follow the server when drives are hosted. Folders get their own Share (public, or a key with read/add/edit rights), since per-folder access is what hosting adds over a bare s3d. Files the server can still read are marked, and Access can encrypt the ones that predate the move.
This commit is contained in:
parent
bd4e9ecff1
commit
f924331a1e
2 changed files with 380 additions and 7 deletions
|
|
@ -303,6 +303,20 @@ export function installHosted(ctx) {
|
|||
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 === '');
|
||||
|
|
|
|||
373
ui/app.js
373
ui/app.js
|
|
@ -154,6 +154,8 @@ const eventHandlers = {
|
|||
},
|
||||
login(d) { state.login = d; emit(); },
|
||||
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
|
||||
move(p) { progressSink?.(p); },
|
||||
convert(p) { progressSink?.(p); },
|
||||
};
|
||||
|
||||
function connectEvents() {
|
||||
|
|
@ -180,7 +182,7 @@ function renderPill() {
|
|||
const label = { running: 's3d running', starting: 'Starting…', stopping: 'Stopping…', crashed: 's3d stopped (error)', 'not connected': 'Not connected to Sia', stopped: 's3d stopped', external: 's3d running (external)' }[st];
|
||||
const pill = $('#daemon-pill');
|
||||
pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`;
|
||||
pill.querySelector('.label').textContent = label;
|
||||
pill.querySelector('.label').textContent = isHosted() ? `On Silent Mode · ${label}` : label;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- router
|
||||
|
|
@ -222,6 +224,23 @@ function overview(main) {
|
|||
const st = daemonState();
|
||||
const busy = st === 'starting' || st === 'stopping';
|
||||
const running = st === 'running' || st === 'starting';
|
||||
if (s.mode === 'hosted') {
|
||||
put(daemonCard,
|
||||
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway on Silent Mode'), h('span', { class: `badge ${st === 'running' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
||||
h('dl', { class: 'kv' },
|
||||
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · your own, connected from this computer'),
|
||||
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, hostedUrl()), h('span', { class: 'small muted' }, ' (path-style)')),
|
||||
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : '–'),
|
||||
h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : 'starts on the next request'),
|
||||
h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(s.hosted?.usage?.down || 0)} this month${s.hosted?.downloadCapBytes ? ` of ${fmtBytes(s.hosted.downloadCapBytes)}` : ''}`),
|
||||
),
|
||||
h('p', { class: 'small muted' }, 'Files are encrypted on this computer before they reach the server. s3d there starts when something asks for your drives and stops when idle.'),
|
||||
h('div', { class: 'row', style: 'margin-top:16px' },
|
||||
h('button', { class: 'btn primary', disabled: busy || running, onclick: () => daemonAction('start') }, 'Start'),
|
||||
h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'),
|
||||
h('a', { class: 'btn', href: '#/settings' }, 'Where your drives live')));
|
||||
return;
|
||||
}
|
||||
put(daemonCard,
|
||||
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
||||
h('dl', { class: 'kv' },
|
||||
|
|
@ -306,6 +325,10 @@ function overview(main) {
|
|||
|
||||
function bannersFor(s) {
|
||||
const out = [];
|
||||
if (s.mode === 'hosted') {
|
||||
if (s.hosted?.error) out.push(h('div', { class: 'banner warn' }, h('span', {}, `Silent Mode is not reachable: ${s.hosted.error}`), h('a', { class: 'btn', href: '#/settings' }, 'Settings')));
|
||||
return out;
|
||||
}
|
||||
if (!s.daemon.binary) {
|
||||
out.push(h('div', { class: 'banner warn' },
|
||||
h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`),
|
||||
|
|
@ -908,7 +931,7 @@ function users(main) {
|
|||
|
||||
function showKey(k, fresh) {
|
||||
const s = state.status;
|
||||
const endpoint = `http://${s?.config.apiAddress || '127.0.0.1:8000'}`;
|
||||
const endpoint = s?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000';
|
||||
const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}
|
||||
aws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}
|
||||
aws configure set region us-east-1 --profile ${k.user}
|
||||
|
|
@ -960,7 +983,7 @@ async function passwordKey(password, salt, iterations) {
|
|||
async function sealCredentials(k, { mode, password }) {
|
||||
const payload = JSON.stringify({
|
||||
user: k.user, accessKeyId: k.accessKeyId, secretKey: k.secretKey,
|
||||
endpoint: `http://${state.status?.config.apiAddress || '127.0.0.1:8000'}`, region: 'us-east-1',
|
||||
endpoint: state.status?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000', region: 'us-east-1',
|
||||
});
|
||||
const head = { format: CRED_FORMAT, v: 1, user: k.user, saved: new Date().toISOString() };
|
||||
if (mode === 'vault') {
|
||||
|
|
@ -1146,7 +1169,7 @@ async function bucketList(head, body, user, userSel) {
|
|||
put(card, h('div', { class: 'tiles' }, list.map((b) => {
|
||||
const access = h('span', { class: 'badge' }, '…');
|
||||
api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => {
|
||||
put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public]));
|
||||
put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: isHosted() ? '🔒 encrypted' : '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public]));
|
||||
}).catch(() => put(access, h('span', { class: 'badge' }, '?')));
|
||||
return h('div', { class: 'tile', tabindex: '0', role: 'button',
|
||||
onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; },
|
||||
|
|
@ -1188,6 +1211,7 @@ async function bucketList(head, body, user, userSel) {
|
|||
}
|
||||
|
||||
async function accessDialog(user, bucket) {
|
||||
if (isHosted()) return hostedAccessDialog(user, bucket);
|
||||
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`;
|
||||
let current = 'private';
|
||||
try { current = (await api('GET', base)).public; } catch (e) { return fail(e); }
|
||||
|
|
@ -1266,9 +1290,11 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) },
|
||||
h('td', {}, '📁 ', f.slice(prefix.length)),
|
||||
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
|
||||
h('td', { class: 'actions' },
|
||||
isHosted() && [h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); folderShareDialog(user, bucket, f); } }, 'Share'), ' '],
|
||||
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
|
||||
objects.map((o) => h('tr', {},
|
||||
h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`)),
|
||||
h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`), readableMark(o)),
|
||||
h('td', { class: 'num' }, fmtBytes(o.size)),
|
||||
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
||||
h('td', { class: 'actions' },
|
||||
|
|
@ -1280,6 +1306,18 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')));
|
||||
}
|
||||
|
||||
// On Silent Mode, files the server can read: shared on purpose, or stored
|
||||
// before the drive moved there.
|
||||
let shareRules = [];
|
||||
if (isHosted()) api('GET', '/api/hosted/rules').then((r) => { shareRules = r; draw(); }).catch(() => {});
|
||||
function readableMark(o) {
|
||||
if (!isHosted() || o.encrypted !== false) return null;
|
||||
const shared = shareRules.some((r) => r.bucket === bucket && (r.type === 'link' ? r.prefix === o.key : o.key.startsWith(r.prefix)));
|
||||
return shared
|
||||
? h('span', { class: 'small muted', title: 'Shared, so stored readable on the server' }, ' 🌐')
|
||||
: h('span', { class: 'small', title: 'Stored readable on the server (from before your drives moved). Encrypt it under Access.' }, ' 🔓');
|
||||
}
|
||||
|
||||
function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; }
|
||||
|
|
@ -1370,6 +1408,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
}
|
||||
|
||||
async function share(key) {
|
||||
if (isHosted()) return hostedShare(key);
|
||||
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
|
||||
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
|
||||
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` });
|
||||
|
|
@ -1391,15 +1430,335 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
|
||||
}
|
||||
|
||||
// On Silent Mode a link is a rule on the server, and the file is stored
|
||||
// readable while it works.
|
||||
async function hostedShare(key) {
|
||||
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
|
||||
const out = h('div');
|
||||
const gen = async () => {
|
||||
put(out, h('span', { class: 'spinner' }));
|
||||
try {
|
||||
const { url } = await api('GET', `${base}/presign?${new URLSearchParams({ key, expires: expires.value })}`);
|
||||
put(out, secretRow('Link', url));
|
||||
} catch (e) { put(out); fail(e); }
|
||||
return false;
|
||||
};
|
||||
await modal('Share link', h('div', { class: 'stack' },
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Anyone with the link can open this file until it ends, even with this computer off. While the link works the file is stored readable on the server; afterwards Pithos encrypts it again.'),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
|
||||
out),
|
||||
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- on Silent Mode
|
||||
|
||||
const isHosted = () => state.status?.mode === 'hosted';
|
||||
const hostedUrl = () => state.status?.hosted?.url || 'https://s3.silentmode.st';
|
||||
// Where S3 clients reach this s3d: the server, or the local address.
|
||||
const s3Endpoint = () => (isHosted() ? hostedUrl() : `http://${state.status?.config.apiAddress}`);
|
||||
|
||||
// Long jobs (moving drives, converting folders) report through events.
|
||||
let progressSink = null;
|
||||
function progressLine(p) {
|
||||
if (p.phase === 'flush') return 'Sending leftovers to Sia…';
|
||||
if (p.phase === 'stop') return 'Stopping s3d…';
|
||||
if (p.phase === 'upload') return `Sending the s3d folder… ${fmtBytes(p.sent)} of ${fmtBytes(p.total)}`;
|
||||
if (p.phase === 'download') return `Bringing the s3d folder back… ${fmtBytes(p.got)} of ${fmtBytes(p.total)}`;
|
||||
if (p.phase === 'start') return 'Starting s3d…';
|
||||
if (p.phase === 'done') return 'Done.';
|
||||
if (p.phase === 'failed') return `Failed: ${p.error}`;
|
||||
if (p.total != null) return `Converting files… ${p.done} of ${p.total}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function hostedCard(card) {
|
||||
let info = null;
|
||||
|
||||
async function load() {
|
||||
try { info = await api('GET', '/api/hosted'); } catch (e) { put(card, h('h2', {}, 'Where your drives live'), h('p', { class: 'error' }, e.message)); return; }
|
||||
draw();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const me = info.me;
|
||||
const intro = h('p', { class: 'small muted' },
|
||||
'On this computer, s3d runs here and needs this computer on. On Silent Mode, the same s3d runs on Silent Mode\'s server with your own Sia account: apps, S3 clients and share links work with this computer off, and you can reach your drives from other devices. ',
|
||||
'Your recovery phrase never leaves this computer, and Pithos encrypts file names and contents before they leave, so the server cannot read them. Only folders you make public or share stay readable.');
|
||||
const parts = [h('div', { class: 'row spread' }, h('h2', {}, 'Where your drives live'),
|
||||
h('span', { class: `badge ${info.mode === 'hosted' ? 'ok' : ''}` }, info.mode === 'hosted' ? 'On Silent Mode' : 'On this computer')), intro];
|
||||
|
||||
if (!info.signedIn) {
|
||||
const url = h('input', { type: 'url', value: info.url, autocomplete: 'off' });
|
||||
parts.push(
|
||||
info.error && h('p', { class: 'small error' }, info.error),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Silent Mode server'), url),
|
||||
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: () => signIn(url.value.trim()) }, 'Sign in to Silent Mode')),
|
||||
h('p', { class: 'small muted' }, info.secrets === 'vault'
|
||||
? 'You sign in with a key from your Theseus vault: the same vault on another computer reaches the same drives.'
|
||||
: 'You sign in with a key kept in a file beside s3d.yml. Back it up below: without it, encrypted files cannot be read.'));
|
||||
} else {
|
||||
parts.push(h('dl', { class: 'kv' },
|
||||
h('dt', {}, 'Server'), h('dd', {}, h('code', {}, info.url)),
|
||||
h('dt', {}, 'Account'), h('dd', {}, h('code', {}, info.id), me?.gate?.name ? ` · ${me.gate.name}` : ''),
|
||||
me && [h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(me.usage?.down || 0)} this month${me.downloadCapBytes ? ` of ${fmtBytes(me.downloadCapBytes)}` : ''}`)],
|
||||
me && [h('dt', {}, 'Drives there'), h('dd', {}, { empty: 'none yet', active: 'yes', 'moving-out': 'being moved back' }[me.state] || me.state)]));
|
||||
const status = h('p', { class: 'small muted' });
|
||||
progressSink = (p) => put(status, progressLine(p));
|
||||
const buttons = [];
|
||||
if (info.mode === 'local' && me?.state === 'empty') {
|
||||
buttons.push(h('button', { class: 'btn primary', onclick: moveUp }, 'Move my drives to Silent Mode'));
|
||||
}
|
||||
if (info.mode === 'local' && me?.state === 'active') {
|
||||
buttons.push(h('button', { class: 'btn primary', onclick: () => setMode('hosted') }, 'Use my drives on Silent Mode'));
|
||||
}
|
||||
if (info.mode === 'hosted') {
|
||||
buttons.push(h('button', { class: 'btn', onclick: moveDown }, 'Move my drives back to this computer'));
|
||||
}
|
||||
buttons.push(h('button', { class: 'btn', onclick: signOut }, 'Sign out'));
|
||||
parts.push(h('div', { class: 'row' }, buttons), status);
|
||||
if (info.mode === 'local' && me?.state === 'empty') {
|
||||
parts.push(h('p', { class: 'small muted' }, 'Moving sends s3d\'s database (your drives, users and keys) and any files still waiting for Sia. Files already on Sia are not uploaded again. This computer keeps a copy of the old folder, set aside.'));
|
||||
}
|
||||
}
|
||||
if (info.secrets === 'file') {
|
||||
parts.push(h('div', { class: 'row', style: 'margin-top:12px' },
|
||||
h('button', { class: 'btn small', onclick: exportKey }, 'Back up my key'),
|
||||
!info.signedIn && h('button', { class: 'btn small', onclick: importKey }, 'Restore a key')));
|
||||
}
|
||||
put(card, ...parts);
|
||||
}
|
||||
|
||||
async function signIn(url, signup) {
|
||||
try {
|
||||
const r = await api('POST', '/api/hosted/signin', { url, signup });
|
||||
if (r.needsSignup) return signUp(url, r);
|
||||
toast('Signed in to Silent Mode');
|
||||
load();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
// A slot on the server costs memory and traffic, so opening one needs a
|
||||
// BNS name you own, a small deposit, or an invite.
|
||||
async function signUp(url, r) {
|
||||
const tabs = [];
|
||||
if (r.gate.bns) tabs.push('bns');
|
||||
if (r.gate.deposit) tabs.push('deposit');
|
||||
if (r.gate.invite) tabs.push('invite');
|
||||
if (!tabs.length) return toast('This server is not taking new accounts', 'error');
|
||||
let kind = tabs[0];
|
||||
const name = h('input', { type: 'text', placeholder: 'yourname.bch', autocomplete: 'off' });
|
||||
const sig = h('textarea', { rows: 3, placeholder: 'Signature (base64)' });
|
||||
const txid = h('input', { type: 'text', placeholder: 'Transaction id', autocomplete: 'off' });
|
||||
const code = h('input', { type: 'text', placeholder: 'Invite code', autocomplete: 'off' });
|
||||
const panes = {
|
||||
bns: h('div', { class: 'stack' },
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Sign this message with the wallet that owns the name (Electron Cash: Tools › Sign/verify message; Cashonize and Paytaca have the same):'),
|
||||
secretRow('Message', r.bnsMessage),
|
||||
h('label', { class: 'field' }, h('span', {}, 'BNS name'), name),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Signature'), sig)),
|
||||
deposit: r.gate.deposit && h('div', { class: 'stack' },
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, `Send at least ${r.gate.deposit.minSats.toLocaleString()} sats to the address below, with this memo (OP_RETURN). It is refundable after fair use.`),
|
||||
secretRow('Address', r.gate.deposit.address),
|
||||
secretRow('Memo', r.depositMemo),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Transaction id'), txid, h('small', {}, 'Wait for one confirmation (about ten minutes).'))),
|
||||
invite: h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'Invite code'), code)),
|
||||
};
|
||||
const holder = h('div');
|
||||
const label = { bns: 'BNS name', deposit: 'Deposit', invite: 'Invite' };
|
||||
const switcher = h('div', { class: 'row' }, tabs.map((t) => h('label', { class: 'check' },
|
||||
h('input', { type: 'radio', name: 'gate', checked: t === kind, onchange: () => { kind = t; put(holder, panes[t]); } }), label[t])));
|
||||
put(holder, panes[kind]);
|
||||
const proof = await modal('Open your space on Silent Mode', h('div', { class: 'stack' },
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Each person gets one slot. Choose how to show you are one person:'),
|
||||
tabs.length > 1 && switcher, holder),
|
||||
[{ label: 'Cancel', result: null }, { label: 'Open my space', kind: 'primary', submit: true, value: () => (
|
||||
kind === 'bns' ? { type: 'bns', name: name.value.trim(), signature: sig.value.trim() }
|
||||
: kind === 'deposit' ? { type: 'deposit', txid: txid.value.trim() }
|
||||
: { type: 'invite', code: code.value.trim() }) }]);
|
||||
if (proof) signIn(url, proof);
|
||||
}
|
||||
|
||||
async function moveUp(ev, force = false) {
|
||||
if (!force && !(await confirmModal('Move your drives to Silent Mode?',
|
||||
'Pithos sends leftovers to Sia, stops s3d here and moves its folder to the server. From then on your drives run on Silent Mode, and new files are encrypted before they leave this computer.', 'Move', 'primary'))) return;
|
||||
try {
|
||||
const r = await api('POST', '/api/hosted/move-up', { force });
|
||||
toast(`Your drives are on Silent Mode (${r.users.length} user${r.users.length === 1 ? '' : 's'})`);
|
||||
await refreshStatus();
|
||||
route();
|
||||
} catch (e) {
|
||||
if (!force && /move anyway/.test(e.message)) {
|
||||
if (await confirmModal('Some uploads have not reached Sia', `${e.message}`, 'Move anyway', 'primary')) return moveUp(ev, true);
|
||||
return;
|
||||
}
|
||||
fail(e);
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDown(ev, force = false) {
|
||||
if (!force && !(await confirmModal('Move your drives back here?',
|
||||
'Silent Mode sends leftovers to Sia, stops its s3d and hands the folder back. s3d then runs on this computer again; files encrypted on Silent Mode stay encrypted and Pithos keeps reading them. Public folders, share links and shared keys stop working.', 'Move back', 'primary'))) return;
|
||||
try {
|
||||
await api('POST', '/api/hosted/move-down', { force });
|
||||
toast('Your drives are back on this computer');
|
||||
await refreshStatus();
|
||||
route();
|
||||
} catch (e) {
|
||||
if (!force && /move anyway/.test(e.message)) {
|
||||
if (await confirmModal('Some uploads have not reached Sia', e.message, 'Move anyway', 'primary')) return moveDown(ev, true);
|
||||
return;
|
||||
}
|
||||
fail(e);
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
async function setMode(mode) {
|
||||
try { await api('POST', '/api/hosted/mode', { mode }); await refreshStatus(); route(); } catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
if (info.mode === 'hosted' && !(await confirmModal('Sign out?', 'Your drives stay on Silent Mode and keep working for apps and links. This computer shows its own s3d until you sign in again.', 'Sign out', 'primary'))) return;
|
||||
try { await api('POST', '/api/hosted/signout'); await refreshStatus(); route(); } catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function exportKey() {
|
||||
try {
|
||||
const k = await api('GET', '/api/hosted/secret');
|
||||
const text = JSON.stringify(k, null, 2);
|
||||
if (bridge) { await bridge.invoke('save-file', { name: 'pithos-hosted-key.json', text }); return; }
|
||||
const a = h('a', { href: URL.createObjectURL(new Blob([text], { type: 'application/json' })), download: 'pithos-hosted-key.json' });
|
||||
document.body.append(a); a.click(); a.remove();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function importKey() {
|
||||
const ta = h('textarea', { rows: 6, placeholder: '{ "v": 1, "secret": "…" }' });
|
||||
const v = await modal('Restore a key', h('div', { class: 'stack' },
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Paste the contents of a pithos-hosted-key.json backup. It replaces the key this computer uses.'),
|
||||
h('div', { class: 'banner warn' }, 'Files encrypted with the current key can only be read with it. Back it up first if anything was encrypted with it.'), ta),
|
||||
[{ label: 'Cancel', result: null }, { label: 'Restore', kind: 'primary', submit: true, value: () => ta.value }]);
|
||||
if (!v) return;
|
||||
try { await api('POST', '/api/hosted/secret', JSON.parse(v)); toast('Key restored'); load(); } catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
// Folder and drive sharing on Silent Mode: public, or a key for someone.
|
||||
async function folderShareDialog(user, bucket, folder) {
|
||||
const where = folder ? `${bucket}/${folder}` : bucket;
|
||||
const list = h('div', { class: 'stack' });
|
||||
async function loadRules() {
|
||||
let rules = [];
|
||||
try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket && r.prefix === folder && r.type !== 'link'); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); }
|
||||
if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Not shared: only you can read it, and it is stored encrypted.'));
|
||||
put(list, rules.map((r) => h('div', { class: 'row spread' },
|
||||
h('span', {}, r.type === 'public'
|
||||
? (r.rights === 'read-list' ? '🌐 Public, with a file list' : '🌐 Public')
|
||||
: `🔑 Key ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}${r.label ? ` · ${r.label}` : ''}${r.expires ? ` · until ${fmtDate(r.expires)}` : ''}`),
|
||||
h('button', { class: 'btn small danger', type: 'button', onclick: () => removeRule(r) }, r.type === 'public' ? 'Make private' : 'Revoke'))));
|
||||
}
|
||||
async function removeRule(r) {
|
||||
try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); loadRules(); } catch (e) { fail(e); }
|
||||
}
|
||||
const kind = h('select', {},
|
||||
h('option', { value: 'public:read' }, 'Anyone: read files'),
|
||||
h('option', { value: 'public:read-list' }, 'Anyone: read files and see the list'),
|
||||
h('option', { value: 'grant:read' }, 'Someone with a key: read'),
|
||||
h('option', { value: 'grant:add' }, 'Someone with a key: add new files'),
|
||||
h('option', { value: 'grant:edit' }, 'Someone with a key: add, change and delete'));
|
||||
const label = h('input', { type: 'text', placeholder: 'Who it is for (optional)' });
|
||||
const until = h('select', {}, [['', 'No expiry'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days']].map(([v, l]) => h('option', { value: v }, l)));
|
||||
const out = h('div', { class: 'stack' });
|
||||
const progress = h('p', { class: 'small muted', style: 'margin:0' });
|
||||
const add = async () => {
|
||||
const [type, rights] = kind.value.split(':');
|
||||
progressSink = (p) => put(progress, progressLine(p));
|
||||
try {
|
||||
const r = await api('POST', '/api/hosted/rules', {
|
||||
user, type, bucket, prefix: folder, rights, label: label.value.trim(),
|
||||
expires: until.value ? Date.now() + Number(until.value) * 1000 : undefined,
|
||||
});
|
||||
put(progress, '');
|
||||
if (r.type === 'public') {
|
||||
const at = `${hostedUrl()}/p/${state.status.hosted.id}/${bucket}/${folder}`;
|
||||
put(out, secretRow(r.rights === 'read-list' ? 'Public address (lists the folder as JSON)' : 'Public address: add a file name', at),
|
||||
r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.'));
|
||||
}
|
||||
else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'),
|
||||
secretRow('S3 endpoint', hostedUrl()), secretRow('Access key', r.accessKeyId), secretRow('Secret key', r.secretKey),
|
||||
secretRow('Bucket and folder', `${bucket}/${folder}`));
|
||||
loadRules();
|
||||
} catch (e) { put(progress, ''); fail(e); }
|
||||
return false;
|
||||
};
|
||||
loadRules();
|
||||
await modal(`Share ${where}`, h('div', { class: 'stack' },
|
||||
list,
|
||||
h('label', { class: 'field' }, h('span', {}, 'Share with'), kind),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Label'), label),
|
||||
h('label', { class: 'field' }, h('span', {}, 'Ends'), until),
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Shared files are stored readable on the server, because the people you share with have no decryption key. Stop sharing and Pithos encrypts them again.'),
|
||||
progress, out),
|
||||
[{ label: 'Close', result: true }, { label: 'Share', kind: 'primary', submit: true, value: add }]);
|
||||
}
|
||||
|
||||
// A drive on Silent Mode: everything shared in it, in one place.
|
||||
async function hostedAccessDialog(user, bucket) {
|
||||
const list = h('div', { class: 'stack' });
|
||||
const progress = h('p', { class: 'small muted', style: 'margin:0' });
|
||||
progressSink = (p) => put(progress, progressLine(p));
|
||||
async function load() {
|
||||
let rules = [];
|
||||
try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); }
|
||||
if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Nothing in this drive is shared. Everything is encrypted, and only you can read it.'));
|
||||
const what = (r) => (r.type === 'public' ? (r.rights === 'read-list' ? '🌐 public + list' : '🌐 public')
|
||||
: r.type === 'link' ? `🔗 link until ${fmtDate(r.expires)}`
|
||||
: `🔑 ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}`);
|
||||
put(list, h('table', {}, h('tbody', {}, rules.map((r) => h('tr', {},
|
||||
h('td', {}, h('code', {}, `/${r.prefix}`)),
|
||||
h('td', {}, what(r), r.label ? h('span', { class: 'small muted' }, ` · ${r.label}`) : ''),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', type: 'button', onclick: async () => {
|
||||
try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); load(); } catch (e) { fail(e); }
|
||||
} }, 'Stop')))))));
|
||||
}
|
||||
load();
|
||||
const encryptOld = h('button', { class: 'btn small', type: 'button', onclick: async () => {
|
||||
encryptOld.disabled = true;
|
||||
try {
|
||||
const r = await api('POST', '/api/hosted/encrypt', { user, bucket });
|
||||
toast(r.converted ? `Encrypted ${r.converted} file${r.converted === 1 ? '' : 's'}` : 'Every private file was already encrypted');
|
||||
} catch (e) { fail(e); }
|
||||
encryptOld.disabled = false;
|
||||
} }, 'Encrypt files stored readable');
|
||||
await modal(`Access for ${bucket}`, h('div', { class: 'stack' },
|
||||
list, progress,
|
||||
h('div', { class: 'row' }, encryptOld, h('span', { class: 'small muted' }, 'Files from before your drives moved here are stored as they were. This encrypts every one that is not shared.')),
|
||||
h('p', { class: 'small muted', style: 'margin:0' }, 'Share a folder from its row in the drive, or the whole drive here. Public files are at ',
|
||||
h('code', {}, `${hostedUrl()}/p/${state.status?.hosted?.id}/${bucket}/<path>`), '.')),
|
||||
[{ label: 'Close', result: true }, { label: 'Share the whole drive…', kind: 'primary', submit: true, value: () => { folderShareDialog(user, bucket, ''); return true; } }]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- settings
|
||||
|
||||
function settings(main) {
|
||||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
||||
const flushCard = h('div', { class: 'card', style: 'margin-top:16px' });
|
||||
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card, flushCard);
|
||||
const whereCard = h('div', { class: 'card', style: 'margin-bottom:16px' });
|
||||
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Where your drives live, and s3d.yml for s3d on this computer. s3d reads it at start, so a restart applies the changes.'))), whereCard, card, flushCard);
|
||||
hostedCard(whereCard);
|
||||
// s3d.yml and the leftovers setting are for s3d on this computer; on Silent
|
||||
// Mode the server manages both (leftovers always go to Sia).
|
||||
if (isHosted()) {
|
||||
put(card, h('h2', {}, 's3d on this computer'), h('p', { class: 'small muted' }, 'Your drives run on Silent Mode, so s3d on this computer is not used. Its settings come back when you move your drives back here.'));
|
||||
flushCard.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kept by Pithos itself (pithos-prefs.json), applies at once, no restart.
|
||||
(async () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue