Theseus: bundle Pithos 0.3.5
Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
This commit is contained in:
parent
5f02973597
commit
1244dae25e
6 changed files with 249 additions and 37 deletions
|
|
@ -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=",
|
||||
|
|
|
|||
21
bundled-addons/pithos/core/prefs.js
Normal file
21
bundled-addons/pithos/core/prefs.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
// "<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) => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}/<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 }]);
|
||||
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 "<name>/" 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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue