Theseus: bundle Pithos 0.3.5

Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
This commit is contained in:
Local Dev 2026-10-04 00:05:50 +02:00
parent 5f02973597
commit 1244dae25e
6 changed files with 249 additions and 37 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pithos", "id": "pithos",
"name": "Pithos", "name": "Pithos",
"version": "0.3.4", "version": "0.3.5",
"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

@ -0,0 +1,21 @@
// Small Pithos preferences kept beside s3d.yml (pithos-prefs.json):
// whether s3d should be running, so a restart of the host (Theseus quitting,
// an extension update) brings it back instead of leaving it stopped.
import fs from 'node:fs';
import path from 'node:path';
const FILE = 'pithos-prefs.json';
export function readPrefs(configFile) {
try { return JSON.parse(fs.readFileSync(path.join(path.dirname(configFile), FILE), 'utf8')) || {}; } catch { return {}; }
}
export function writePrefs(configFile, patch) {
const next = { ...readPrefs(configFile), ...patch };
try {
fs.mkdirSync(path.dirname(configFile), { recursive: true });
fs.writeFileSync(path.join(path.dirname(configFile), FILE), JSON.stringify(next, null, 2));
} catch { /* a read-only config folder just means no memory */ }
return next;
}

View file

@ -22,6 +22,7 @@ import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js';
import * as admin from './admin.js'; import * as admin from './admin.js';
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js'; import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
import * as accounts from './accounts.js'; import * as accounts from './accounts.js';
import { readPrefs, writePrefs } from './prefs.js';
const HERE = path.dirname(fileURLToPath(import.meta.url)); const HERE = path.dirname(fileURLToPath(import.meta.url));
const UI_DIR = path.join(HERE, '..', 'ui'); const UI_DIR = path.join(HERE, '..', 'ui');
@ -46,6 +47,7 @@ export async function createPithos(opts = {}) {
allowedHosts = [], // extra Host header values (reverse proxy names) allowedHosts = [], // extra Host header values (reverse proxy names)
hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only
openExternal, // (url) => void, for hosts that can open the system browser openExternal, // (url) => void, for hosts that can open the system browser
showPanel, // () => void, for hosts with a side panel to switch back to (Theseus)
} = opts; } = opts;
const configFile = resolveConfigPath(configOpt); const configFile = resolveConfigPath(configOpt);
@ -82,6 +84,7 @@ export async function createPithos(opts = {}) {
// Registration costs an s3d process run, so it is cached and only // Registration costs an s3d process run, so it is cached and only
// recomputed when something that could change it happens. // recomputed when something that could change it happens.
let regCache = null; let regCache = null;
let lastAutoTry = 0;
async function registration() { async function registration() {
// A "not connected" answer is re-checked after 30 s: the connection can // A "not connected" answer is re-checked after 30 s: the connection can
// be made outside Pithos (s3d login by hand, another Pithos). // be made outside Pithos (s3d login by hand, another Pithos).
@ -132,6 +135,12 @@ export async function createPithos(opts = {}) {
route('GET', '/api/status', async () => { route('GET', '/api/status', async () => {
const c = cfg(); const c = cfg();
// Backstop for the start-up autostart: opening Pithos also brings s3d
// back if the user wants it running (at most every 30 s).
if (!daemon.child && daemon.state !== 'starting' && Date.now() - lastAutoTry > 30_000 && readPrefs(configFile).autostart === true) {
lastAutoTry = Date.now();
autostart('Pithos was opened');
}
const v = await version(); const v = await version();
return { return {
host: hostName, host: hostName,
@ -144,11 +153,14 @@ export async function createPithos(opts = {}) {
}; };
}); });
// Start and Stop also record what the user wants, so s3d comes back by
// itself after Theseus restarts or Pithos is updated.
route('POST', '/api/daemon/start', async () => { route('POST', '/api/daemon/start', async () => {
ensureConfig(configFile); ensureConfig(configFile);
writePrefs(configFile, { autostart: true });
return daemon.start(); return daemon.start();
}); });
route('POST', '/api/daemon/stop', () => daemon.stop()); route('POST', '/api/daemon/stop', () => { writePrefs(configFile, { autostart: false }); return daemon.stop(); });
route('POST', '/api/daemon/restart', async () => { route('POST', '/api/daemon/restart', async () => {
await daemon.stop(); await daemon.stop();
ensureConfig(configFile); ensureConfig(configFile);
@ -203,11 +215,28 @@ export async function createPithos(opts = {}) {
const [users, keys] = await Promise.all([cli.listUsers(), cli.listKeys()]); const [users, keys] = await Promise.all([cli.listUsers(), cli.listKeys()]);
return users.map((name) => ({ name, keys: keys.filter((k) => k.user === name).map((k) => k.accessKeyId) })); return users.map((name) => ({ name, keys: keys.filter((k) => k.user === name).map((k) => k.accessKeyId) }));
}); });
// Every user gets a bucket of their own (named after them unless the
// caller picks a name), so there is somewhere to put files straight away.
route('POST', '/api/users', async (req) => { route('POST', '/api/users', async (req) => {
const { name, withKey = true } = await jsonBody(req); const { name, withKey = true, bucket } = await jsonBody(req);
await cli.createUser(name); await cli.createUser(name);
const key = withKey ? await cli.createKey(name) : null; const key = withKey ? await cli.createKey(name) : null;
return { name, key }; let created = null;
let bucketError = null;
if (key && bucket !== false && daemon.state === 'running') {
const client = new S3Client({ endpoint: `http://${cfg().apiAddress}`, accessKeyId: key.accessKeyId, secretKey: key.secretKey });
const base = bucket ? String(bucket) : bucketNameFor(name);
for (let i = 0; i < 5 && !created; i++) {
const candidate = i ? `${base.slice(0, 60)}-${i + 1}` : base;
try { await client.createBucket(candidate); created = candidate; }
catch (e) {
if (e.code === 'BucketAlreadyExists' || e.code === 'BucketAlreadyOwnedByYou') continue;
bucketError = e.message;
break;
}
}
}
return { name, key, bucket: created, bucketError };
}); });
route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; }); route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; });
route('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined)); route('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined));
@ -317,6 +346,29 @@ export async function createPithos(opts = {}) {
if (!prefix) throw new HttpError(400, 'prefix required'); if (!prefix) throw new HttpError(400, 'prefix required');
return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) }; return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) };
}); });
// S3 has no folders, only names with slashes. An empty object named
// "<folder>/" is the usual marker that keeps an empty folder visible.
route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => {
let prefix = url.searchParams.get('prefix') || '';
if (!prefix || prefix.startsWith('/') || prefix.includes('//')) throw new HttpError(400, 'folder name required');
if (!prefix.endsWith('/')) prefix += '/';
await (await s3For(p.user)).putObject(p.bucket, prefix, Buffer.alloc(0), { contentLength: 0 });
return { prefix };
});
// Small text files for the viewer (first 512 KiB).
route('GET', '/api/s3/:user/buckets/:bucket/text', async (req, url, p) => {
const up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' });
const body = await readBody(up);
if (up.statusCode >= 300) throw new HttpError(up.statusCode, body.match(/<Message>(.*?)<\/Message>/)?.[1] || `HTTP ${up.statusCode}`);
const total = Number((up.headers['content-range'] || '').split('/')[1]) || body.length;
return { text: body, truncated: total > 524288, size: total };
});
route('POST', '/api/host/show-panel', () => {
if (!showPanel) throw new HttpError(400, 'not supported by this host');
showPanel();
return { ok: true };
});
route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => { route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => {
const q = url.searchParams; const q = url.searchParams;
const client = await s3For(p.user); const client = await s3For(p.user);
@ -364,10 +416,10 @@ export async function createPithos(opts = {}) {
if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); } if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); }
let url = new URL(req.url, `http://${req.headers.host}`); let url = new URL(req.url, `http://${req.headers.host}`);
let viaTicket = false; let viaTicket = false;
const dl = /^\/dl\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname); const dl = /^\/(dl|v)\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname);
if (dl && req.method === 'GET') { if (dl && (req.method === 'GET' || req.method === 'HEAD')) {
const t = tickets.get(dl[1]); const t = tickets.get(dl[2]);
tickets.delete(dl[1]); if (dl[1] === 'dl' || (t && !t.reuse)) tickets.delete(dl[2]);
if (!t || t.exp < Date.now()) { res.writeHead(410); return res.end('link expired'); } if (!t || t.exp < Date.now()) { res.writeHead(410); return res.end('link expired'); }
url = new URL(t.path, url); url = new URL(t.path, url);
viaTicket = true; viaTicket = true;
@ -447,6 +499,28 @@ export async function createPithos(opts = {}) {
server.listen(port, host, resolve); server.listen(port, host, resolve);
}); });
boundPort = server.address().port; boundPort = server.address().port;
// Bring s3d back if the user had it running. A crash (not a Stop) is
// retried a few times with a pause, but never a missing Sia connection.
let restarts = [];
lastAutoTry = Date.now();
const wantRunning = () => readPrefs(configFile).autostart === true;
async function autostart(reason) {
if (daemon.child) return;
if (!wantRunning()) return; // the user stopped it, or never started it
if (!daemon.binary) { daemon.pushLog('sys', 'not starting s3d: it is not installed'); return; }
if (await probeExternal(cfg())) { daemon.pushLog('sys', 'not starting s3d: another s3d already serves this config'); return; }
try { ensureConfig(configFile); daemon.start(); daemon.pushLog('sys', `started by Pithos (${reason})`); }
catch (e) { daemon.pushLog('sys', `could not start s3d: ${e.message}`); }
}
daemon.on('status', (st) => {
if (st.state !== 'crashed' || st.needsLogin || !wantRunning()) return;
restarts = restarts.filter((t) => Date.now() - t < 10 * 60_000);
if (restarts.length >= 3) { daemon.pushLog('sys', 's3d keeps stopping; not restarting again for now. See the log above.'); return; }
restarts.push(Date.now());
setTimeout(() => autostart('it stopped unexpectedly'), 5000).unref?.();
});
setTimeout(() => autostart('it was running before'), 500).unref?.();
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`; const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
@ -464,6 +538,14 @@ export async function createPithos(opts = {}) {
return `pithos=${id}`; return `pithos=${id}`;
}, },
// A single-use, 60-second URL for one object download. // A single-use, 60-second URL for one object download.
// A 10-minute link that streams one object inline, for the viewer in
// hosts whose page has no session (the Theseus panel).
viewUrl(apiPath) {
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only objects');
const id = crypto.randomBytes(24).toString('base64url');
tickets.set(id, { path: apiPath + '&inline=1', exp: Date.now() + 10 * 60_000, reuse: true });
return `${baseUrl}v/${id}`;
},
downloadUrl(apiPath) { downloadUrl(apiPath) {
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads'); if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads');
const id = crypto.randomBytes(24).toString('base64url'); const id = crypto.randomBytes(24).toString('base64url');
@ -520,6 +602,14 @@ function getIn(obj, keys) {
return keys.reduce((o, k) => (o == null ? undefined : o[k]), obj); return keys.reduce((o, k) => (o == null ? undefined : o[k]), obj);
} }
// A valid S3 bucket name from a user name: lowercase letters, digits and
// hyphens, 3-63 characters.
function bucketNameFor(name) {
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (b.length < 3) b = (b || 'my') + '-files';
return b.slice(0, 63).replace(/-+$/, '');
}
function cmpVersion(a, b) { function cmpVersion(a, b) {
const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0);
const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0);

View file

@ -34,6 +34,8 @@ module.exports = {
hostName: "theseus", hostName: "theseus",
binDir: path.join(dataDir, "bin"), binDir: path.join(dataDir, "bin"),
openExternal: (url) => api.openTab(url), openExternal: (url) => api.openTab(url),
// "Back to sidebar" from the full-tab view.
showPanel: () => api.revealSidebar("main"),
}); });
cookie = p.internalSession(); cookie = p.internalSession();
pithos = p; pithos = p;
@ -109,6 +111,12 @@ module.exports = {
api.openTab(url); api.openTab(url);
return { ok: true }; return { ok: true };
}); });
// Viewer: a 10-minute inline link for one object (images, audio, video).
api.onMessage("view-url", async ({ path: apiPath } = {}) => {
const p = await ensureServer();
return { url: p.viewUrl(apiPath) };
});
// A full browser-tab view, for anyone who prefers it to the sidebar. // A full browser-tab view, for anyone who prefers it to the sidebar.
api.onMessage("open-in-tab", async () => { api.onMessage("open-in-tab", async () => {
const p = await ensureServer(); const p = await ensureServer();
@ -231,6 +239,10 @@ module.exports = {
return { saved: true }; return { saved: true };
}); });
// Start the control server with Theseus, so an s3d the user left running
// comes back after a restart or an update without opening Pithos first.
ensureServer().catch((e) => api.log("control server failed to start:", e.message));
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" }); api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" });
// The host has no quit hook for add-ons; without this an s3d we started // The host has no quit hook for add-ons; without this an s3d we started

View file

@ -229,3 +229,17 @@ html, body { overflow-x: hidden; }
.account-line { margin-top: 4px; } .account-line { margin-top: 4px; }
.account-line a { color: var(--accent); } .account-line a { color: var(--accent); }
/* Buckets as folder tiles */
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(170px, 100%), 1fr)); gap: 12px; }
.tile { border: 1px solid var(--border); border-radius: 10px; padding: 14px; cursor: pointer; display: flex; flex-direction: column; gap: 6px; background: var(--surface-2); }
.tile:hover, .tile:focus-visible { border-color: var(--accent); outline: none; }
.tile-icon { font-size: 34px; line-height: 1; }
.tile-name { font-weight: 600; overflow-wrap: anywhere; }
.tile-actions { display: flex; gap: 6px; margin-top: 4px; }
/* Viewer */
.modal:has(.viewer) { width: min(920px, 100%); }
.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-text { max-height: 60vh; white-space: pre-wrap; overflow-wrap: anywhere; }

View file

@ -850,14 +850,20 @@ function users(main) {
async function createUser() { async function createUser() {
const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' }); const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' });
const withKey = h('input', { type: 'checkbox', checked: true }); const withKey = h('input', { type: 'checkbox', checked: true });
// The user's own bucket, named after them unless edited.
const bucket = h('input', { type: 'text', pattern: '[a-z0-9][a-z0-9-]{1,61}[a-z0-9]', placeholder: 'bucket name' });
let bucketEdited = false;
bucket.addEventListener('input', () => { bucketEdited = true; });
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: '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 }) }]); [{ 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;
try { try {
const r = await api('POST', '/api/users', v); const r = await api('POST', '/api/users', v);
toast(`Created ${r.name}`); toast(r.bucket ? `Created ${r.name} with bucket ${r.bucket}` : r.bucketError ? `Created ${r.name}; bucket not created: ${r.bucketError}` : `Created ${r.name} (start s3d to give them a bucket)`);
expanded.add(r.name); expanded.add(r.name);
if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); } if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); }
await load(); await load();
@ -1035,6 +1041,24 @@ function openCredentialsDialog() {
// ---------------------------------------------------------------- buckets & objects // ---------------------------------------------------------------- buckets & objects
const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || '';
function fileKind(key) {
const e = EXT(key);
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg', 'ico'].includes(e)) return 'image';
if (['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus'].includes(e)) return 'audio';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(e)) return 'video';
if (['txt', 'md', 'json', 'csv', 'log', 'xml', 'yml', 'yaml', 'toml', 'ini', 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'py', 'go', 'rs', 'sh', 'sql'].includes(e)) return 'text';
return null;
}
function suggestBucket(name) {
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (b && b.length < 3) b += '-files';
return b.slice(0, 63).replace(/-+$/, '');
}
function fileIcon(key) {
return { image: '🖼️', audio: '🎵', video: '🎬', text: '📄' }[fileKind(key)] || '📦';
}
function buckets(main, [bucket, ...prefixParts]) { function buckets(main, [bucket, ...prefixParts]) {
const head = h('div', { class: 'page-head' }); const head = h('div', { class: 'page-head' });
const body = h('div'); const body = h('div');
@ -1063,6 +1087,11 @@ function buckets(main, [bucket, ...prefixParts]) {
if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel); if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel);
else bucketList(head, body, state.selectedUser, userSel); else bucketList(head, body, state.selectedUser, userSel);
})(); })();
// Follow s3d: the page was drawn for one state; redraw when it flips.
const runningNow = () => ['running', 'external'].includes(daemonState());
let wasRunning = runningNow();
cleanup.push(onState(() => { if (runningNow() !== wasRunning) { wasRunning = runningNow(); route(); } }));
} }
function userPicker(userSel) { function userPicker(userSel) {
@ -1100,22 +1129,22 @@ async function bucketList(head, body, user, userSel) {
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 buckets yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a bucket'))); return; }
put(card, h('table', {}, // Buckets read as top-level folders.
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Created'), h('th', {}, 'Access'), h('th', {}))), put(card, h('div', { class: 'tiles' }, list.map((b) => {
h('tbody', {}, list.map((b) => { const access = h('span', { class: 'badge' }, '…');
const access = h('td', {}, h('span', { class: 'badge' }, '…')); api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => {
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: 'private', read: 'public read', 'read-list': 'public read + list', custom: 'custom policy' }[p.public])); }).catch(() => put(access, h('span', { class: 'badge' }, '?')));
}).catch(() => put(access, h('span', { class: 'badge' }, '?'))); return h('div', { class: 'tile', tabindex: '0', role: 'button',
return h('tr', { class: 'clickable', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; },
h('td', {}, h('strong', {}, b.name)), onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } },
h('td', { class: 'hide-sm muted' }, fmtDate(b.created)), h('div', { class: 'tile-icon' }, '📁'),
access, h('div', { class: 'tile-name' }, b.name),
h('td', { class: 'actions' }, h('div', { class: 'tile-meta' }, access),
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), 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 danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete'))); h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete')));
})))); })));
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
} }
@ -1151,7 +1180,8 @@ async function accessDialog(user, bucket) {
opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' }, opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' },
h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }), h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }),
h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))), h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))),
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.')), h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.'),
h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')),
[{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]); [{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]);
if (!v || v === current) return; if (!v || v === current) return;
try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); } try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); }
@ -1215,15 +1245,12 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }), 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' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
objects.map((o) => h('tr', {}, objects.map((o) => h('tr', {},
h('td', {}, h('a', { h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`)),
href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '',
// The sidebar page has no server origin; Theseus's download
// manager fetches the file through a one-time link instead.
onclick: bridge ? (e) => { e.preventDefault(); bridge.invoke('download', { path: `${base}/object?key=${encodeURIComponent(o.key)}` }).then(() => toast('Download started')).catch(fail); } : null,
}, o.key.slice(prefix.length))),
h('td', { class: 'num' }, fmtBytes(o.size)), h('td', { class: 'num' }, fmtBytes(o.size)),
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
h('td', { class: 'actions' }, h('td', { class: 'actions' },
h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'),
' ',
h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'),
' ', ' ',
h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))),
@ -1268,11 +1295,45 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
} }
} }
// An empty "<name>/" marker object keeps the folder visible while empty.
async function newFolder() { async function newFolder() {
const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' }); const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' });
const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'S3 has no real folders. This opens an empty prefix; it appears once you upload into it.')), const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name),
[{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => name.value.trim() }]); [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]);
if (v) go(prefix + v); if (!v) return;
try { await api('PUT', `${base}/folder?prefix=${encodeURIComponent(prefix + v + '/')}`); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
}
function downloadObject(key) {
const path = `${base}/object?key=${encodeURIComponent(key)}`;
// The sidebar page has no server origin; Theseus's download manager
// fetches the file through a one-time link instead.
if (bridge) return bridge.invoke('download', { path }).then(() => toast('Download started')).catch(fail);
const a = h('a', { href: path, download: '' });
document.body.append(a);
a.click();
a.remove();
}
// Viewer: images, audio, video and text open inside Pithos; anything else
// offers a download.
async function openViewer(o) {
const name = o.key.slice(prefix.length);
const kind = fileKind(o.key);
const path = `${base}/object?key=${encodeURIComponent(o.key)}`;
const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`);
let content;
try {
if (kind === 'image') content = h('img', { class: 'viewer-media', src: await src(), alt: name });
else if (kind === 'audio') content = h('audio', { class: 'viewer-audio', src: await src(), controls: true, autoplay: true });
else if (kind === 'video') content = h('video', { class: 'viewer-media', src: await src(), controls: true, autoplay: true });
else if (kind === 'text') {
const t = await api('GET', `${base}/text?key=${encodeURIComponent(o.key)}`);
content = h('div', {}, h('pre', { class: 'snippet viewer-text' }, t.text), t.truncated && h('p', { class: 'small muted' }, `Showing the first 512 KiB of ${fmtBytes(t.size)}.`));
} else content = h('div', { class: 'empty' }, h('p', {}, 'Pithos cannot show this kind of file.'), h('p', { class: 'small' }, 'Download it to open it on your computer.'));
} catch (e) { return fail(e); }
await modal(name, h('div', { class: 'viewer' }, content, h('p', { class: 'small muted', style: 'margin:10px 0 0' }, `${fmtBytes(o.size)} · ${fmtDate(o.modified)}`)),
[{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]);
} }
async function deleteObject(key) { async function deleteObject(key) {
@ -1416,7 +1477,10 @@ function addSidebarControls() {
bridge.sidebar.onMaxChange?.(label); bridge.sidebar.onMaxChange?.(label);
row.append(widen); row.append(widen);
} }
row.append(h('button', { class: 'btn small', title: 'Open Pithos in a browser tab', onclick: () => bridge.invoke('open-in-tab').catch(fail) }, 'Open in tab')); // Full page: open in a tab and close the panel, so only one Pithos shows.
row.append(h('button', { class: 'btn small', title: 'Open Pithos as a full page and close this panel', onclick: async () => {
try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); }
} }, 'Full page'));
$('.sidebar').insertBefore(row, $('#daemon-pill')); $('.sidebar').insertBefore(row, $('#daemon-pill'));
} }
@ -1453,6 +1517,16 @@ $('#unlock-btn').addEventListener('click', async () => {
} }
}); });
// The full-page tab opened from the Theseus panel: one button back.
function addBackToSidebar() {
const row = h('div', { class: 'host-controls' },
h('button', { class: 'btn small', title: 'Show Pithos in the sidebar again and close this page', onclick: async () => {
try { await api('POST', '/api/host/show-panel'); } catch (e) { return fail(e); }
window.close(); // works for tabs the add-on opened; harmless otherwise
} }, '⇤ Back to sidebar'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
}
function showSignin() { function showSignin() {
$('#app').hidden = true; $('#app').hidden = true;
$('#signin').hidden = false; $('#signin').hidden = false;
@ -1480,6 +1554,7 @@ async function boot() {
state.logSeq = state.logs.at(-1)?.seq || 0; state.logSeq = state.logs.at(-1)?.seq || 0;
} catch {} } catch {}
await refreshStatus(); await refreshStatus();
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
connectEvents(); connectEvents();
window.addEventListener('hashchange', route); window.addEventListener('hashchange', route);
route(); route();