pithos/web/app.js

375 lines
17 KiB
JavaScript
Raw Normal View History

// 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);