pithos/web/app.js
Local Dev b8ec745a43 Pithos in the browser: Drives on Silent Mode with a key file
A page served by the gateway that signs in with the Pithos key file
(Ed25519 in Web Crypto) or an Export JSON S3 key, then browses, views,
downloads, uploads, renames and deletes, encrypting and decrypting in the
page with the same formats as the desktop. Checked against a local
gateway and real s3d: files the desktop encrypted open byte-identical in
the browser, and browser uploads open in the desktop client; the server
only ever holds encrypted names. Remember on this device keeps the key
sealed with a passphrase in IndexedDB.
2026-10-05 02:25:02 +02:00

374 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pithos in the browser: Drives on Silent Mode, opened with a key file.
// Everything is decrypted in this page; see lib/hosted.js for the protocol.
import * as Hosted from './lib/hosted.js';
const $ = (s) => document.querySelector(s);
const h = (tag, attrs = {}, ...kids) => {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
return el;
};
const put = (el, ...kids) => { el.replaceChildren(); for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); };
function toast(msg, err) {
const t = h('div', { class: `toast${err ? ' err' : ''}` }, msg);
$('#toasts').append(t);
setTimeout(() => t.remove(), err ? 7000 : 3500);
return t;
}
const fail = (e) => toast(e?.message || String(e), true);
function fmtBytes(n) {
if (!Number.isFinite(n)) return '';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${u[i]}`;
}
const fmtDate = (s) => { const d = new Date(s); return Number.isNaN(+d) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); };
const baseName = (p) => p.replace(/\/$/, '').split('/').pop();
const KIND = (k) => {
const e = k.split('.').pop().toLowerCase();
if (/^(png|jpe?g|gif|webp|svg|avif)$/.test(e)) return 'image';
if (/^(mp3|ogg|oga|wav|flac|m4a|opus)$/.test(e)) return 'audio';
if (/^(mp4|webm|mov)$/.test(e)) return 'video';
if (/^(txt|md|json|csv|log|xml|yml|yaml|ini|conf|js|css|html?)$/.test(e)) return 'text';
return 'file';
};
const ICON = { folder: '📁', image: '🖼️', audio: '🎵', video: '🎬', text: '📄', file: '📦', drive: '💽' };
// ---- session -----------------------------------------------------------------------------
const S = { key: null, token: null, drives: null, user: null, fixedBucket: null, label: '' };
async function signInWith(text) {
const key = await Hosted.readKeyFile(text);
if (key.kind === 'owner') {
const s = await Hosted.signIn(key.identitySeed);
if (s.state !== 'active') throw new Error(s.state === 'empty' ? 'Nothing is on Silent Mode for this account yet. Move your drives there from Pithos first.' : 'This account is being moved; try again in a few minutes.');
const s3 = await Hosted.ownerS3(s.token);
const token = s.token;
let rulesCache = null;
S.token = token;
S.user = s3.user;
S.label = `Signed in · ${s.id.slice(0, 8)}…`;
try { const me = await Hosted.api('GET', '/hosted/v1/me', { token }); if (me.gate?.name) S.label = `Signed in as ${me.gate.name}`; } catch {}
S.drives = new Hosted.Drives({
accessKeyId: s3.accessKeyId, secretKey: s3.secretKey, master: key.encryptionKey,
rules: async () => {
if (!rulesCache || Date.now() - rulesCache.at > 60_000) rulesCache = { at: Date.now(), list: await Hosted.api('GET', '/hosted/v1/rules', { token }).catch(() => []) };
return Array.isArray(rulesCache.list) ? rulesCache.list : rulesCache.list?.rules || [];
},
});
} else {
if (key.endpoint && new URL(key.endpoint).host !== location.host && !/silentmode\.st$/.test(new URL(key.endpoint).hostname)) {
throw new Error(`This key is for ${new URL(key.endpoint).host}, not Drives on Silent Mode. Use it with Pithos or another S3 app.`);
}
S.drives = new Hosted.Drives({ accessKeyId: key.accessKeyId, secretKey: key.secretKey });
S.fixedBucket = key.bucket || null;
S.label = `S3 key ${key.accessKeyId.slice(0, 6)}…`;
}
S.key = key;
}
function signedIn() {
$('#signin').hidden = true;
$('#app').hidden = false;
$('#who').textContent = S.label;
$('#who').hidden = false;
$('#signout').hidden = false;
if (!location.hash || location.hash === '#/') location.hash = S.fixedBucket ? `#/d/${encodeURIComponent(S.fixedBucket)}/` : '#/';
route();
}
async function signOut() {
if (S.token) await Hosted.signOut(S.token);
location.hash = '';
location.reload();
}
// ---- sign-in screen --------------------------------------------------------------------------
async function showSignin() {
$('#app').hidden = true;
$('#signin').hidden = false;
const saved = await Hosted.remembered();
$('#unlock').hidden = !saved;
$('#keyform').hidden = !!saved;
if (saved) $('#unlock-pass').focus();
}
function signinError(e) {
const el = $('#signin-error');
el.textContent = e?.message || String(e);
el.hidden = false;
}
async function trySignIn(text, rememberPass) {
$('#signin-error').hidden = true;
const btns = [$('#go'), ...document.querySelectorAll('#unlock-form button')];
btns.forEach((b) => { b.disabled = true; });
try {
await signInWith(text);
if (rememberPass) { await Hosted.remember(text, rememberPass); }
signedIn();
} catch (e) { signinError(e); } finally { btns.forEach((b) => { b.disabled = false; }); }
}
let pickedText = null;
async function pickFile(file) {
if (!file) return;
if (file.size > 64 * 1024) return signinError(new Error('That file is too big to be a key file.'));
pickedText = await file.text();
$('#drop strong').textContent = `Key: ${file.name}`;
}
function wireSignin() {
const drop = $('#drop');
drop.addEventListener('click', () => $('#keyfile').click());
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#keyfile').click(); } });
$('#keyfile').addEventListener('change', () => pickFile($('#keyfile').files[0]));
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); pickFile(e.dataTransfer.files[0]); });
$('#remember').addEventListener('change', () => { $('#pass-row').hidden = !$('#remember').checked; });
$('#go').addEventListener('click', () => {
const text = pickedText || $('#keytext').value.trim();
if (!text) return signinError(new Error('Choose your key file first, or paste it.'));
let pass = null;
if ($('#remember').checked) {
pass = $('#pass').value;
if (pass.length < 10) return signinError(new Error('Use a passphrase of at least 10 characters to remember the key.'));
}
trySignIn(text, pass);
});
$('#unlock-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const text = await Hosted.recall($('#unlock-pass').value);
if (!text) return showSignin();
await trySignIn(text, null);
} catch (err) { signinError(err); }
});
$('#forget').addEventListener('click', async () => {
await Hosted.forget();
$('#unlock').hidden = true;
$('#keyform').hidden = false;
});
$('#signout').addEventListener('click', signOut);
}
// ---- routes: #/ (drives) · #/d/<drive>/<folder>/ -----------------------------------------------
function current() {
const m = /^#\/d\/([^/]+)\/(.*)$/.exec(location.hash);
if (!m) return { bucket: null, prefix: '' };
const prefix = m[2].split('/').map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join('/');
return { bucket: decodeURIComponent(m[1]), prefix };
}
const linkTo = (bucket, prefix = '') => `#/d/${encodeURIComponent(bucket)}/${prefix.split('/').map(encodeURIComponent).join('/')}`;
let routeSeq = 0;
async function route() {
if (!S.drives) return;
const seq = ++routeSeq;
const { bucket, prefix } = current();
if (!bucket) return showDrives(seq);
return showFolder(seq, bucket, prefix);
}
function crumbs(bucket, prefix) {
const parts = [];
if (!S.fixedBucket) parts.push(h('a', { href: '#/' }, 'Drives'));
if (bucket) {
if (parts.length) parts.push(h('span', { class: 'sep' }, '›'));
parts.push(h('a', { href: linkTo(bucket) }, `${ICON.drive} ${bucket}`));
let acc = '';
for (const seg of prefix.split('/').filter(Boolean)) {
acc += seg + '/';
parts.push(h('span', { class: 'sep' }, '›'), h('a', { href: linkTo(bucket, acc) }, seg));
}
}
put($('#crumbs'), parts);
}
async function showDrives(seq) {
crumbs(null, '');
$('#toolbar').hidden = true;
put($('#list'), h('p', { class: 'empty' }, 'Loading…'));
try {
const list = await S.drives.listBuckets();
if (seq !== routeSeq) return;
if (!list.length) return put($('#list'), h('p', { class: 'empty' }, 'No drives yet. Create one in Pithos.'));
put($('#list'), h('table', { class: 'files' },
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', { class: 'hide-sm' }, 'Created'))),
h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(b.name); } },
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.drive), h('span', { class: 'label' }, b.name))),
h('td', { class: 'num hide-sm' }, fmtDate(b.created)))))));
$('#count').textContent = '';
} catch (e) {
if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message));
}
}
async function showFolder(seq, bucket, prefix) {
crumbs(bucket, prefix);
$('#toolbar').hidden = false;
put($('#list'), h('p', { class: 'empty' }, 'Loading…'));
let l;
try {
l = await S.drives.list(bucket, prefix);
} catch (e) {
// a folder key may only list its own folder: go there
const only = /may only list (.+)$/.exec(e.message || '');
if (only && !prefix && seq === routeSeq) { location.hash = linkTo(bucket, only[1]); return; }
if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message));
return;
}
if (seq !== routeSeq) return;
const rows = [];
for (const f of l.folders) {
rows.push(h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(bucket, f.path); } },
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.folder), h('span', { class: 'label' }, baseName(f.path)), f.locked && h('span', { class: 'lock' }, 'encrypted'))),
h('td', { class: 'num hide-sm' }, ''), h('td', { class: 'num hide-sm' }, ''),
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() },
!S.fixedBucket || S.key.kind === 'owner' ? h('button', { class: 'btn small danger', onclick: () => deleteFolder(bucket, f.path) }, 'Delete') : null)));
}
for (const o of l.files) {
const kind = KIND(o.key);
const open = o.locked ? null : () => view(bucket, o);
rows.push(h('tr', { class: open ? 'clickable' : '', onclick: open },
h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON[kind]), h('span', { class: 'label', title: o.key }, baseName(o.key)), o.locked && h('span', { class: 'lock' }, 'encrypted'))),
h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
h('td', { class: 'num hide-sm' }, fmtDate(o.modified)),
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() },
!o.locked && h('button', { class: 'btn small', onclick: () => download(bucket, o.key) }, 'Download'), ' ',
!o.locked && h('button', { class: 'btn small', onclick: () => rename(bucket, o.key) }, 'Rename'), ' ',
h('button', { class: 'btn small danger', onclick: () => remove(bucket, o.key) }, 'Delete'))));
}
$('#count').textContent = `${l.folders.length} folders · ${l.files.length} files`;
if (!rows.length) return put($('#list'), h('p', { class: 'empty' }, 'This folder is empty. Drop files here or press Upload.'));
put($('#list'), h('table', { class: 'files' },
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
h('tbody', {}, rows)));
}
// ---- actions ------------------------------------------------------------------------------------------
async function uploadFiles(files) {
const { bucket, prefix } = current();
if (!bucket || !files.length) return;
for (const file of files) {
const bar = h('progress', { max: file.size || 1, value: 0 });
const t = h('div', { class: 'toast' }, `Uploading ${file.name}`, bar);
$('#toasts').append(t);
try {
await S.drives.write(bucket, prefix + file.name, file, (n) => { bar.value = n; });
t.replaceChildren(`Uploaded ${file.name}`);
} catch (e) { t.classList.add('err'); t.replaceChildren(`${file.name}: ${e.message}`); }
setTimeout(() => t.remove(), 4000);
}
route();
}
async function newFolder() {
const { bucket, prefix } = current();
const name = (prompt('Folder name') || '').trim();
if (!name) return;
if (name.includes('/')) return fail(new Error('A folder name cannot contain /'));
try { await S.drives.mkdir(bucket, `${prefix}${name}/`); route(); } catch (e) { fail(e); }
}
function saveBlob(blob, name) {
const url = URL.createObjectURL(blob);
const a = h('a', { href: url, download: name });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
async function download(bucket, key) {
const t = toast(`Preparing ${baseName(key)}…`);
try { saveBlob(await S.drives.blob(bucket, key), baseName(key)); t.remove(); } catch (e) { t.remove(); fail(e); }
}
async function rename(bucket, key) {
const name = (prompt('New name', baseName(key)) || '').trim();
if (!name || name === baseName(key)) return;
if (name.includes('/')) return fail(new Error('A name cannot contain /'));
const t = toast(`Renaming ${baseName(key)}…`);
try { await S.drives.rename(bucket, key, key.slice(0, key.length - baseName(key).length) + name); t.remove(); route(); } catch (e) { t.remove(); fail(e); }
}
async function remove(bucket, key) {
if (!confirm(`Delete ${baseName(key)}? This cannot be undone.`)) return;
try { await S.drives.remove(bucket, key); toast('Deleted'); route(); } catch (e) { fail(e); }
}
async function deleteFolder(bucket, prefix) {
if (!confirm(`Delete the folder ${baseName(prefix)} and everything in it? This cannot be undone.`)) return;
const t = toast(`Deleting ${baseName(prefix)}…`);
try { await S.drives.removeFolder(bucket, prefix); t.remove(); toast('Folder deleted'); route(); } catch (e) { t.remove(); fail(e); }
}
// ---- viewer ---------------------------------------------------------------------------------------------
let viewerUrl = null;
async function view(bucket, o) {
const kind = KIND(o.key);
if (kind === 'file') return download(bucket, o.key);
const body = $('#viewer-body');
$('#viewer-name').textContent = baseName(o.key);
put(body, h('p', { class: 'muted' }, 'Opening…'));
$('#viewer').hidden = false;
$('#viewer-dl').onclick = () => download(bucket, o.key);
try {
let src;
if (!o.encrypted && (kind === 'audio' || kind === 'video')) src = await S.drives.presigned(bucket, o.storageKey);
else {
if (kind !== 'text' && o.size > 300 * 1024 * 1024) throw new Error('This file is too large to open in the browser; download it instead.');
const blob = await S.drives.blob(bucket, o.key);
if (kind === 'text') {
if (blob.size > 2 * 1024 * 1024) throw new Error('This text file is too large to show; download it instead.');
return put(body, h('pre', {}, await blob.text()));
}
viewerUrl = URL.createObjectURL(blob);
src = viewerUrl;
}
if (kind === 'image') put(body, h('img', { src, alt: baseName(o.key) }));
else if (kind === 'audio') put(body, h('audio', { src, controls: true, autoplay: true }));
else put(body, h('video', { src, controls: true, autoplay: true }));
} catch (e) { put(body, h('p', { class: 'error' }, e.message)); }
}
function closeViewer() {
$('#viewer').hidden = true;
put($('#viewer-body'));
if (viewerUrl) { URL.revokeObjectURL(viewerUrl); viewerUrl = null; }
}
// ---- boot ------------------------------------------------------------------------------------------------
function wireApp() {
$('#upload').addEventListener('click', () => $('#files').click());
$('#files').addEventListener('change', () => { uploadFiles([...$('#files').files]); $('#files').value = ''; });
$('#newfolder').addEventListener('click', newFolder);
const list = $('#list');
list.addEventListener('dragover', (e) => { if (current().bucket) { e.preventDefault(); list.classList.add('over'); } });
list.addEventListener('dragleave', () => list.classList.remove('over'));
list.addEventListener('drop', (e) => { e.preventDefault(); list.classList.remove('over'); uploadFiles([...e.dataTransfer.files]); });
$('#viewer-close').addEventListener('click', closeViewer);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('#viewer').hidden) closeViewer(); });
window.addEventListener('hashchange', route);
}
wireSignin();
wireApp();
showSignin().catch(fail);