Theseus: bundle Pithos 0.3.6

Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
This commit is contained in:
Local Dev 2026-10-04 00:09:55 +02:00
parent 1244dae25e
commit 3de25581b3
5 changed files with 41 additions and 13 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pithos", "id": "pithos",
"name": "Pithos", "name": "Pithos",
"version": "0.3.5", "version": "0.3.6",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",

View file

@ -363,6 +363,17 @@ export async function createPithos(opts = {}) {
const total = Number((up.headers['content-range'] || '').split('/')[1]) || body.length; const total = Number((up.headers['content-range'] || '').split('/')[1]) || body.length;
return { text: body, truncated: total > 524288, size: total }; return { text: body, truncated: total > 524288, size: total };
}); });
// Drive usage: files and bytes in a bucket, counted up to 20 pages.
route('GET', '/api/s3/:user/buckets/:bucket/usage', async (req, url, p) => {
const client = await s3For(p.user);
let files = 0, bytes = 0, token, pages = 0;
do {
const page = await client.listObjects(p.bucket, { delimiter: '', token });
for (const o of page.objects) { if (!o.key.endsWith('/')) { files++; bytes += o.size || 0; } }
token = page.truncated ? page.nextToken : null;
} while (token && ++pages < 20);
return { files, bytes, partial: !!token };
});
route('POST', '/api/host/show-panel', () => { route('POST', '/api/host/show-panel', () => {
if (!showPanel) throw new HttpError(400, 'not supported by this host'); if (!showPanel) throw new HttpError(400, 'not supported by this host');
showPanel(); showPanel();

View file

@ -243,3 +243,10 @@ html, body { overflow-x: hidden; }
.viewer-media { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; background: var(--surface-2); } .viewer-media { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; background: var(--surface-2); }
.viewer-audio { width: 100%; } .viewer-audio { width: 100%; }
.viewer-text { max-height: 60vh; white-space: pre-wrap; overflow-wrap: anywhere; } .viewer-text { max-height: 60vh; white-space: pre-wrap; overflow-wrap: anywhere; }
/* Drive icon (a bucket): a small disk with a status light */
.drive-icon { width: 46px; height: 30px; border-radius: 7px; position: relative;
background: linear-gradient(180deg, var(--surface) 0%, var(--surface-2) 100%);
border: 2px solid var(--muted); box-shadow: inset 0 -7px 0 color-mix(in srgb, var(--muted) 25%, transparent); }
.drive-icon::after { content: ""; position: absolute; right: 7px; bottom: 3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.tile:hover .drive-icon { border-color: var(--accent); }

View file

@ -829,7 +829,7 @@ function users(main) {
h('td', {}, h('strong', {}, u.name)), h('td', {}, h('strong', {}, u.name)),
h('td', {}, `${u.keys.length}`), h('td', {}, `${u.keys.length}`),
h('td', { class: 'actions' }, h('td', { class: 'actions' },
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Buckets'), h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Drives'),
' ', ' ',
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'),
' ', ' ',
@ -857,7 +857,7 @@ function users(main) {
name.addEventListener('input', () => { if (!bucketEdited) bucket.value = suggestBucket(name.value); }); name.addEventListener('input', () => { if (!bucketEdited) bucket.value = suggestBucket(name.value); });
const v = await modal('New user', h('div', { class: 'stack' }, const v = await modal('New user', h('div', { class: 'stack' },
h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')), h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')),
h('label', { class: 'field' }, h('span', {}, 'Their bucket'), bucket, h('small', {}, 'Created with the user, like a home folder. Lowercase letters, digits and hyphens. They can make more later.')), h('label', { class: 'field' }, h('span', {}, 'Their drive'), bucket, h('small', {}, 'Created with the user, like a home drive (an S3 bucket). Lowercase letters, digits and hyphens. They can add more later.')),
h('label', { class: 'check' }, withKey, 'Create an access key now')), h('label', { class: 'check' }, withKey, 'Create an access key now')),
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked, bucket: bucket.value.trim() || undefined }) }]); [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked, bucket: bucket.value.trim() || undefined }) }]);
if (!v) return; if (!v) return;
@ -1072,7 +1072,7 @@ function buckets(main, [bucket, ...prefixParts]) {
catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; } catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; }
const usable = users.filter((u) => u.keys.length); const usable = users.filter((u) => u.keys.length);
if (!usable.length) { if (!usable.length) {
put(head, h('div', {}, h('h1', {}, 'Buckets'))); put(head, h('div', {}, h('h1', {}, 'Drives')));
put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys'))); put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys')));
return; return;
} }
@ -1080,7 +1080,7 @@ function buckets(main, [bucket, ...prefixParts]) {
put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name))); put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name)));
const st = daemonState(); const st = daemonState();
if (st !== 'running' && st !== 'external') { if (st !== 'running' && st !== 'external') {
put(head, h('div', {}, h('h1', {}, 'Buckets'))); put(head, h('div', {}, h('h1', {}, 'Drives')));
put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d'))); put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d')));
return; return;
} }
@ -1119,8 +1119,8 @@ function accountLine() {
async function bucketList(head, body, user, userSel) { async function bucketList(head, body, user, userSel) {
put(head, put(head,
h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Top-level storage containers, like drives. Each belongs to one S3 user.'), accountLine()), h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()),
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket'))); h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive')));
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
put(body, card); put(body, card);
const base = `/api/s3/${encodeURIComponent(user)}/buckets`; const base = `/api/s3/${encodeURIComponent(user)}/buckets`;
@ -1128,8 +1128,8 @@ async function bucketList(head, body, user, userSel) {
async function load() { async function load() {
try { try {
const list = await api('GET', base); const list = await api('GET', base);
if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No buckets yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a bucket'))); return; } if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; }
// Buckets read as top-level folders. // Buckets read as drives: the top level you put folders into.
put(card, h('div', { class: 'tiles' }, list.map((b) => { put(card, h('div', { class: 'tiles' }, list.map((b) => {
const access = h('span', { class: 'badge' }, '…'); const access = h('span', { class: 'badge' }, '…');
api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => { api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => {
@ -1138,8 +1138,9 @@ async function bucketList(head, body, user, userSel) {
return h('div', { class: 'tile', tabindex: '0', role: 'button', return h('div', { class: 'tile', tabindex: '0', role: 'button',
onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; }, onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; },
onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } },
h('div', { class: 'tile-icon' }, '📁'), h('div', { class: 'drive-icon', 'aria-hidden': 'true' }),
h('div', { class: 'tile-name' }, b.name), h('div', { class: 'tile-name' }, b.name),
usageLine(b.name),
h('div', { class: 'tile-meta' }, access), h('div', { class: 'tile-meta' }, access),
h('div', { class: 'tile-actions' }, h('div', { class: 'tile-actions' },
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'),
@ -1148,8 +1149,17 @@ async function bucketList(head, body, user, userSel) {
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
} }
// Files and size on a drive, counted in the background.
function usageLine(bucketName) {
const el = h('div', { class: 'small muted' }, 'counting…');
api('GET', `${base}/${encodeURIComponent(bucketName)}/usage`)
.then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`))
.catch(() => put(el, ''));
return el;
}
async function create() { async function create() {
const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-bucket' }); const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-drive' });
const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')), const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')),
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]); [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]);
if (!v) return; if (!v) return;
@ -1193,7 +1203,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; }; const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; };
const crumbs = h('div', { class: 'crumbs' }, const crumbs = h('div', { class: 'crumbs' },
h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Buckets'), h('span', { class: 'sep' }, '/'), h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Drives'), h('span', { class: 'sep' }, '/'),
h('button', { onclick: () => go('') }, bucket), h('button', { onclick: () => go('') }, bucket),
prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)])); prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)]));

View file

@ -34,7 +34,7 @@
<nav id="nav"> <nav id="nav">
<a href="#/start" data-view="start">Get started</a> <a href="#/start" data-view="start">Get started</a>
<a href="#/overview" data-view="overview">Overview</a> <a href="#/overview" data-view="overview">Overview</a>
<a href="#/buckets" data-view="buckets">Buckets</a> <a href="#/buckets" data-view="buckets">Drives</a>
<a href="#/users" data-view="users">Users &amp; keys</a> <a href="#/users" data-view="users">Users &amp; keys</a>
<a href="#/setup" data-view="setup">Sia connection</a> <a href="#/setup" data-view="setup">Sia connection</a>
<a href="#/settings" data-view="settings">Settings</a> <a href="#/settings" data-view="settings">Settings</a>