diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 93e7171a..929e8381 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "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.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/core/prefs.js b/bundled-addons/pithos/core/prefs.js new file mode 100644 index 00000000..12e5f891 --- /dev/null +++ b/bundled-addons/pithos/core/prefs.js @@ -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; +} diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 2ea42a34..ed5a91f5 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -22,6 +22,7 @@ import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js'; import * as admin from './admin.js'; import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js'; import * as accounts from './accounts.js'; +import { readPrefs, writePrefs } from './prefs.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const UI_DIR = path.join(HERE, '..', 'ui'); @@ -46,6 +47,7 @@ export async function createPithos(opts = {}) { allowedHosts = [], // extra Host header values (reverse proxy names) hostName = 'web', // 'web' | 'desktop' | 'theseus' โ€” UI tweaks only 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; 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 // recomputed when something that could change it happens. let regCache = null; + let lastAutoTry = 0; async function registration() { // A "not connected" answer is re-checked after 30 s: the connection can // be made outside Pithos (s3d login by hand, another Pithos). @@ -132,6 +135,12 @@ export async function createPithos(opts = {}) { route('GET', '/api/status', async () => { 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(); return { 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 () => { ensureConfig(configFile); + writePrefs(configFile, { autostart: true }); 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 () => { await daemon.stop(); ensureConfig(configFile); @@ -203,11 +215,28 @@ export async function createPithos(opts = {}) { 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) })); }); + // 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) => { - const { name, withKey = true } = await jsonBody(req); + const { name, withKey = true, bucket } = await jsonBody(req); await cli.createUser(name); 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('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'); return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) }; }); + // S3 has no folders, only names with slashes. An empty object named + // "/" 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>/)?.[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) => { const q = url.searchParams; 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'); } let url = new URL(req.url, `http://${req.headers.host}`); let viaTicket = false; - const dl = /^\/dl\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname); - if (dl && req.method === 'GET') { - const t = tickets.get(dl[1]); - tickets.delete(dl[1]); + const dl = /^\/(dl|v)\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname); + if (dl && (req.method === 'GET' || req.method === 'HEAD')) { + const t = tickets.get(dl[2]); + 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'); } url = new URL(t.path, url); viaTicket = true; @@ -447,6 +499,28 @@ export async function createPithos(opts = {}) { server.listen(port, host, resolve); }); 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 baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`; @@ -464,6 +538,14 @@ export async function createPithos(opts = {}) { return `pithos=${id}`; }, // 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) { if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads'); 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); } +// 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) { const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index f9c58c4e..0531eaa8 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -34,6 +34,8 @@ module.exports = { hostName: "theseus", binDir: path.join(dataDir, "bin"), openExternal: (url) => api.openTab(url), + // "Back to sidebar" from the full-tab view. + showPanel: () => api.revealSidebar("main"), }); cookie = p.internalSession(); pithos = p; @@ -109,6 +111,12 @@ module.exports = { api.openTab(url); 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. api.onMessage("open-in-tab", async () => { const p = await ensureServer(); @@ -231,6 +239,10 @@ module.exports = { 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" }); // The host has no quit hook for add-ons; without this an s3d we started diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index e8a63242..e334c690 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -229,3 +229,17 @@ html, body { overflow-x: hidden; } .account-line { margin-top: 4px; } .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; } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 53cca242..74c4b933 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -850,14 +850,20 @@ function users(main) { 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 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' }, 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')), - [{ 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; try { 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); if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); } await load(); @@ -1035,6 +1041,24 @@ function openCredentialsDialog() { // ---------------------------------------------------------------- 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]) { const head = h('div', { class: 'page-head' }); const body = h('div'); @@ -1063,6 +1087,11 @@ function buckets(main, [bucket, ...prefixParts]) { if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), 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) { @@ -1100,22 +1129,22 @@ async function bucketList(head, body, user, userSel) { try { 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; } - put(card, h('table', {}, - h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Created'), h('th', {}, 'Access'), h('th', {}))), - h('tbody', {}, list.map((b) => { - const access = h('td', {}, 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', 'read-list': 'public read + list', custom: 'custom policy' }[p.public])); - }).catch(() => put(access, h('span', { class: 'badge' }, '?'))); - return h('tr', { class: 'clickable', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, - h('td', {}, h('strong', {}, b.name)), - h('td', { class: 'hide-sm muted' }, fmtDate(b.created)), - access, - h('td', { class: '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'))); - })))); + // Buckets read as top-level folders. + 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])); + }).catch(() => put(access, h('span', { class: 'badge' }, '?'))); + return h('div', { class: 'tile', tabindex: '0', role: 'button', + onclick: () => { 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: 'tile-name' }, b.name), + h('div', { class: 'tile-meta' }, 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'))); + }))); } 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' }, 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('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. Writes and deletes always need a key.')), + h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. 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 }]); if (!v || v === current) return; 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: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))), objects.map((o) => h('tr', {}, - h('td', {}, h('a', { - 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', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`)), h('td', { class: 'num' }, fmtBytes(o.size)), h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), 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 danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), @@ -1268,11 +1295,45 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } } + // An empty "/" marker object keeps the folder visible while empty. async function newFolder() { 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.')), - [{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => name.value.trim() }]); - if (v) go(prefix + v); + const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name), + [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]); + 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) { @@ -1416,7 +1477,10 @@ function addSidebarControls() { bridge.sidebar.onMaxChange?.(label); 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')); } @@ -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() { $('#app').hidden = true; $('#signin').hidden = false; @@ -1480,6 +1554,7 @@ async function boot() { state.logSeq = state.logs.at(-1)?.seq || 0; } catch {} await refreshStatus(); + if (!bridge && state.status?.host === 'theseus') addBackToSidebar(); connectEvents(); window.addEventListener('hashchange', route); route();