Theseus: bundle Pithos 0.3.12

New installs carry the same Pithos the extension channel serves.
This commit is contained in:
Local Dev 2026-10-04 04:06:04 +02:00
parent 0514d2d4e3
commit 001fce02e7
9 changed files with 332 additions and 10 deletions

View file

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

@ -27,6 +27,7 @@ const FORWARD = [
['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/], ['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/],
['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/], ['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/],
['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/], ['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/],
['GET', /^\/api\/space$/],
]; ];
// Things that act on this computer's s3d and make no sense while drives live // Things that act on this computer's s3d and make no sense while drives live
// on the server. // on the server.

View file

@ -12,7 +12,7 @@ import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { S3Client, readBody, objectPath } from './s3.js'; import { S3Client, S3Error, readBody, objectPath } from './s3.js';
import { import {
driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN, driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN,
} from './crypt.js'; } from './crypt.js';
@ -124,7 +124,8 @@ export function plainCovered(rules, bucket, key) {
} }
const MIME = { const MIME = {
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript',
wasm: 'application/wasm', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', webmanifest: 'application/manifest+json',
json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip', json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip',
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon',
mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus', mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus',
@ -270,6 +271,50 @@ export class EncryptingS3Client {
return withStatus(out, status, headers); return withStatus(out, status, headers);
} }
// Renames (the routes' renameObject / renamePrefix). Encrypted names and
// contents are bound to their path and drive, so only a readable file
// staying readable can use s3d's server-side copy; everything else streams
// through here, decrypted and encrypted again for its new place.
async renameObject(bucket, from, to, dstBucket = bucket) {
if (from === to && dstBucket === bucket) return;
const dk = await this.dk(bucket);
const ek = encryptPath(dk, from);
const head = await this.inner.request('HEAD', objectPath(bucket, ek));
head.resume();
const srcPlain = head.statusCode === 404 || head.statusCode === 403;
if (srcPlain && await this.storeAsPlain(dstBucket, to)) {
await this.inner.renameObject(bucket, from, to, dstBucket);
// An older encrypted copy under the new name would shadow this one.
await this.inner.deleteObject(dstBucket, encryptPath(await this.dk(dstBucket), to)).catch(() => {});
return;
}
if (from.endsWith('/')) {
await this.putObject(dstBucket, to, Buffer.alloc(0), { contentLength: 0 });
} else {
const src = await this.getObject(bucket, from);
if (src.statusCode >= 300) { src.resume?.(); throw new Error(`could not read ${from}: HTTP ${src.statusCode}`); }
await this.putObject(dstBucket, to, src, { contentType: guessType(to), contentLength: Number(src.headers['content-length']) });
}
await this.deleteObject(bucket, from);
}
async renamePrefix(bucket, from, to, dstBucket = bucket) {
// Sharing rules name their drive; a renamed drive would silently lose them.
if (dstBucket !== bucket && this.encryptWrites && (await this.rules()).some((r) => r.bucket === bucket)) {
throw new S3Error(409, 'Conflict', 'Stop sharing in this drive (Access) before renaming it; its shared folders and links belong to the old name.');
}
// Listed in full first, so the loop never walks into its own copies.
const keys = [];
let token;
do {
const page = await this.listObjects(bucket, { prefix: from, delimiter: '', token });
for (const o of page.objects) keys.push(o.key);
token = page.truncated ? page.nextToken : null;
} while (token);
for (const k of keys) await this.renameObject(bucket, k, to + k.slice(from.length), dstBucket);
return keys.length;
}
async deleteObject(bucket, key) { async deleteObject(bucket, key) {
const dk = await this.dk(bucket); const dk = await this.dk(bucket);
const ek = encryptPath(dk, key); const ek = encryptPath(dk, key);

View file

@ -136,6 +136,39 @@ export class S3Client {
async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); } async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); }
// Server-side copy. s3d copies the entry in its own database: the copy
// points at the same data on Sia, so nothing is uploaded again.
copyObject(srcBucket, srcKey, dstBucket, dstKey) {
return this.call('PUT', objectPath(dstBucket, dstKey), { headers: { 'x-amz-copy-source': objectPath(srcBucket, srcKey) } });
}
// Every key under prefix (no folder grouping), all pages.
async allKeys(bucket, prefix = '') {
const keys = [];
let token;
do {
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
keys.push(...page.objects.map((o) => o.key));
token = page.truncated ? page.nextToken : null;
} while (token);
return keys;
}
// S3 has no rename: copy to the new name, then delete the old one.
async renameObject(bucket, from, to, dstBucket = bucket) {
await this.copyObject(bucket, from, dstBucket, to);
await this.deleteObject(bucket, from);
}
// Rename a folder (or move a whole drive's contents): every key under
// `from` moves under `to`. Keys are listed first, so the loop never
// walks into the copies it makes. Returns the number moved.
async renamePrefix(bucket, from, to, dstBucket = bucket) {
const keys = await this.allKeys(bucket, from);
for (const k of keys) await this.renameObject(bucket, k, to + k.slice(from.length), dstBucket);
return keys.length;
}
// Deletes every key under prefix. Returns the number deleted. // Deletes every key under prefix. Returns the number deleted.
async deletePrefix(bucket, prefix) { async deletePrefix(bucket, prefix) {
let n = 0; let n = 0;

View file

@ -26,6 +26,8 @@ import { readPrefs, writePrefs } from './prefs.js';
import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js'; import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js';
import { installHosted } from './hosted-routes.js'; import { installHosted } from './hosted-routes.js';
import { accountInfo, readConnection, fingerprint } from './sia-account.js'; import { accountInfo, readConnection, fingerprint } from './sia-account.js';
import { guessType } from './hosted.js';
import { accountSpace } from './space.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');
@ -408,6 +410,75 @@ 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) };
}); });
// ---- renames: files, folders, drives -----------------------------------
// Server-side copies, so they are quick and use no extra Sia storage.
// Every client Pithos makes can rename; the check stays for any that
// cannot (encrypted names and contents are tied to their path, so a raw
// server-side copy would break them).
async function renamer(user) {
const s3 = await s3For(user);
if (typeof s3.renamePrefix !== 'function') throw new HttpError(409, 'Renaming is not available for drives on Silent Mode yet.');
return s3;
}
const badName = (n) => !n || n.includes('/') || n === '.' || n === '..';
async function exists(s3, bucket, prefix) {
const page = await s3.listObjects(bucket, { prefix, delimiter: '', max: 1 });
return page.objects.some((o) => o.key === prefix || prefix.endsWith('/'));
}
// A file: { from: "<key>", name: "<new name>" } in the same folder.
route('POST', '/api/s3/:user/buckets/:bucket/rename-object', async (req, url, p) => {
const { from, name } = await jsonBody(req);
if (!from || from.endsWith('/') || badName(name)) throw new HttpError(400, 'a file and a new name (without "/") are required');
const to = from.slice(0, from.lastIndexOf('/') + 1) + name;
if (to === from) return { key: to };
const s3 = await renamer(p.user);
if (await exists(s3, p.bucket, to)) throw new HttpError(409, `"${name}" already exists here`);
await s3.renameObject(p.bucket, from, to);
return { key: to };
});
// A folder: { from: "<prefix>/", name: "<new name>" } beside it.
route('POST', '/api/s3/:user/buckets/:bucket/rename-prefix', async (req, url, p) => {
const { from, name } = await jsonBody(req);
if (!from || !from.endsWith('/') || badName(name)) throw new HttpError(400, 'a folder and a new name (without "/") are required');
const parent = from.slice(0, from.slice(0, -1).lastIndexOf('/') + 1);
const to = `${parent}${name}/`;
if (to === from) return { prefix: to, moved: 0 };
const s3 = await renamer(p.user);
if (await exists(s3, p.bucket, to)) throw new HttpError(409, `a folder "${name}" already exists here`);
return { prefix: to, moved: await s3.renamePrefix(p.bucket, from, to) };
});
// A drive: a new bucket gets every file and the same access, then the old
// one goes. S3 apps that use the old name need the new one.
route('POST', '/api/s3/:user/buckets/:bucket/rename', async (req, url, p) => {
const { name } = await jsonBody(req);
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(String(name || ''))) throw new HttpError(400, 'drive names are 3-63 lowercase letters, digits, dots and hyphens');
if (name === p.bucket) return { name };
const s3 = await renamer(p.user);
if ((await s3.listBuckets()).some((b) => b.name === name)) throw new HttpError(409, `a drive "${name}" already exists`);
await s3.createBucket(name);
// Public access carries over, rebuilt for the new name (a policy names its bucket).
const mode = describePolicy(await s3.getPolicy(p.bucket).catch(() => null));
const moved = await s3.renamePrefix(p.bucket, '', '', name);
if (mode === 'read' || mode === 'read-list') await s3.putPolicy(name, publicReadPolicy(name, { list: mode === 'read-list' }));
await s3.deleteBucket(p.bucket);
return { name, moved };
});
// ---- free space on the Sia account ----------------------------------------
// From the indexer (signed with s3d's app key). Cached for a minute.
let spaceCache = { at: 0, value: null };
route('GET', '/api/space', async () => {
// On Silent Mode this call is forwarded: the server's s3d answers for its own folder.
if (Date.now() - spaceCache.at < 60_000 && spaceCache.value) return spaceCache.value;
try {
const s = await accountSpace(cfg().directory);
spaceCache = { at: Date.now(), value: s ? { available: true, ...s } : { available: false, reason: 'not connected' } };
} catch (e) {
spaceCache = { at: Date.now() - 45_000, value: { available: false, reason: e.message } };
}
return spaceCache.value;
});
// S3 has no folders, only names with slashes. An empty object named // S3 has no folders, only names with slashes. An empty object named
// "<folder>/" is the usual marker that keeps an empty folder visible. // "<folder>/" is the usual marker that keeps an empty folder visible.
route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => { route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => {
@ -418,6 +489,68 @@ export async function createPithos(opts = {}) {
return { prefix }; return { prefix };
}); });
// Small text files for the viewer (first 512 KiB). // Small text files for the viewer (first 512 KiB).
// HTML opened as a page. A ticket covers the folder the page sits in, so
// its relative CSS, images, scripts and links resolve; it lasts an hour from
// the last use (12 hours at most). See servePage for the sandbox.
const pages = new Map(); // id -> { user, bucket, prefix, exp, until }
route('POST', '/api/s3/:user/buckets/:bucket/page', async (req, url, p) => {
const { key } = await jsonBody(req);
if (typeof key !== 'string' || !/\.html?$/i.test(key)) throw new HttpError(400, 'only .html files open as pages');
const now = Date.now();
for (const [k, v] of pages) if (v.exp < now) pages.delete(k);
if (pages.size >= 200) throw new HttpError(429, 'too many pages open; close some and try again');
const cut = key.lastIndexOf('/') + 1;
const id = crypto.randomBytes(24).toString('base64url');
pages.set(id, { user: p.user, bucket: p.bucket, prefix: key.slice(0, cut), exp: now + 60 * 60_000, until: now + 12 * 3600_000 });
return { path: `/page/${id}/${key.slice(cut).split('/').map(encodeURIComponent).join('/')}` };
});
// The page and its neighbours. The sandbox CSP (no allow-same-origin) gives
// the page an opaque origin: its scripts run, but requests they make to this
// server are cross-site, so they carry no session cookie and cannot pass
// the x-pithos check. They can only read files the ticket covers.
// The page loads whatever it links to (it is the user's own site); the
// sandbox is the boundary, not a source list.
const PAGE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads; frame-ancestors 'self'";
async function servePage(req, res, id, rest) {
const t = pages.get(id);
const now = Date.now();
if (!t || t.exp < now) { res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8' }); return res.end('This page link has expired. Open the file from Pithos again.'); }
t.exp = Math.min(now + 60 * 60_000, t.until);
let segs;
try { segs = rest.split('/').map((x) => decodeURIComponent(x)); } catch { res.writeHead(400); return res.end('bad path'); }
if (!segs.length || segs[segs.length - 1] === '') segs[segs.length ? segs.length - 1 : 0] = 'index.html';
// An encoded slash would make one "segment" span folders (..%2F..).
if (segs.some((x) => !x || x === '.' || x === '..' || /[/\\]/.test(x))) { res.writeHead(400); return res.end('bad path'); }
const key = t.prefix + segs.join('/');
const up = await (await s3For(t.user)).getObject(t.bucket, key, { range: req.headers.range });
if (up.statusCode >= 300 && up.statusCode !== 304) {
up.resume?.();
res.writeHead(up.statusCode === 416 ? 416 : 404, { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': PAGE_CSP });
return res.end(up.statusCode === 416 ? 'range not satisfiable' : 'not found');
}
// The extension decides for web files: S3 clients often store HTML, CSS or
// scripts as octet-stream (or anything), and a page must load them as such.
const guessed = guessType(key);
const stored = up.headers['content-type'];
let type = guessed !== 'application/octet-stream' ? guessed : (stored || guessed);
if (/^text\/|javascript|json|xml|svg/i.test(type) && !/charset=/i.test(type)) type += '; charset=utf-8';
const headers = {
'content-type': type,
'content-security-policy': PAGE_CSP,
'x-content-type-options': 'nosniff',
'cache-control': 'no-store',
// The sandboxed page is cross-origin even to itself; its scripts may read
// the files its ticket covers (the ticket in the URL is the secret).
'access-control-allow-origin': '*',
};
for (const h of ['content-length', 'content-range', 'accept-ranges', 'last-modified', 'etag']) if (up.headers[h]) headers[h] = up.headers[h];
res.writeHead(up.statusCode, headers);
if (req.method === 'HEAD') { up.resume?.(); return res.end(); }
up.on('error', () => res.destroy());
up.pipe(res);
}
route('GET', '/api/s3/:user/buckets/:bucket/text', async (req, url, p) => { 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 up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' });
const body = await readBody(up); const body = await readBody(up);
@ -536,8 +669,18 @@ export async function createPithos(opts = {}) {
url = new URL(t.path, url); url = new URL(t.path, url);
viaTicket = true; viaTicket = true;
} }
res.setHeader('x-frame-options', 'DENY');
res.setHeader('referrer-policy', 'no-referrer'); res.setHeader('referrer-policy', 'no-referrer');
const page = /^\/page\/([A-Za-z0-9_-]{20,})\/(.*)$/.exec(url.pathname);
if (page && (req.method === 'GET' || req.method === 'HEAD')) {
// A page may frame its own folder's files; nothing else frames Pithos.
res.setHeader('x-frame-options', 'SAMEORIGIN');
try { return await servePage(req, res, page[1], page[2]); } catch (e) {
if (res.headersSent) return res.destroy();
res.writeHead(e instanceof S3Error && e.status < 500 ? e.status : 502, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(e.message);
}
}
res.setHeader('x-frame-options', 'DENY');
try { try {
// Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie. // Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie.

View file

@ -0,0 +1,29 @@
// Free space on the Sia account behind this s3d, as the indexer reports it.
// The key reading, BLAKE2b-256 and request signing live in one place
// (sia-account.js, blake2b.js); this keeps the names the space route and its
// tests use.
import { readConnection, signRequest, accountInfo } from './sia-account.js';
export { blake2b256 } from './blake2b.js';
export const signIndexerUrl = (appKey, method, endpointUrl, validUntilSec) => signRequest(appKey, method, endpointUrl, validUntilSec);
export async function readAppKey(dataDir) {
const c = await readConnection(dataDir).catch(() => null);
return c?.appKey && c.indexerUrl ? { appKey: c.appKey, indexerUrl: c.indexerUrl } : null;
}
// { maxPinnedData, pinnedData, remainingStorage, indexer } in bytes, or null
// when this s3d is not connected. Throws when the indexer does not answer.
export async function accountSpace(dataDir, { fetchImpl = fetch } = {}) {
const a = await accountInfo(dataDir, { fetchImpl, timeoutMs: 15_000 });
if (!a.connected) return null;
if (a.error) throw new Error(a.error);
return {
maxPinnedData: Number(a.maxPinnedData),
pinnedData: Number(a.pinnedData),
remainingStorage: Number(a.remainingStorage),
indexer: new URL(a.indexerUrl).host,
};
}

View file

@ -182,6 +182,15 @@ module.exports = {
} }); } });
} }
// An HTML file from a drive, opened as a page in a new Theseus tab. The
// control server sandboxes it (no access to Pithos or the session).
api.onMessage("open-page", async ({ path: pagePath } = {}) => {
if (!/^\/page\/[A-Za-z0-9_-]{20,}\/[^\s]*$/.test(String(pagePath || ""))) throw new Error("not a page link");
const p = await ensureServer();
api.openTab(new URL(pagePath, p.url).href);
return { ok: true };
});
// Full page: Pithos at pithos.sia/<user>/<drive>/… in an ordinary tab. // Full page: Pithos at pithos.sia/<user>/<drive>/… in an ordinary tab.
// An older Theseus without site routes gets the loopback address. // An older Theseus without site routes gets the loopback address.
api.onMessage("open-in-tab", async ({ path: appPath } = {}) => { api.onMessage("open-in-tab", async ({ path: appPath } = {}) => {

View file

@ -416,3 +416,8 @@ body.has-foot.has-player .toasts { bottom: 112px; }
/* "More options" disclosure in dialogs */ /* "More options" disclosure in dialogs */
details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; } details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
details.more-opts > summary:hover { color: var(--text); } details.more-opts > summary:hover { color: var(--text); }
/* Free space on the Sia account */
.space { margin-top: 6px; max-width: 420px; }
.space-bar { margin-top: 4px; height: 6px; }
.space-bar.full > i { background: var(--warn); }

View file

@ -284,7 +284,7 @@ function overview(main) {
put(daemonCard, put(daemonCard,
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
h('dl', { class: 'kv' }, h('dl', { class: 'kv' },
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')), h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect'), spaceLine()),
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)), h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)),
s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))], s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))],
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')), h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')),
@ -1194,6 +1194,7 @@ function fileKind(key) {
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'; 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; return null;
} }
const isHtml = (key) => ['html', 'htm'].includes(EXT(key));
function suggestBucket(name) { function suggestBucket(name) {
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (b && b.length < 3) b += '-files'; if (b && b.length < 3) b += '-files';
@ -1281,6 +1282,19 @@ function siaAccountBlock(a, onRefresh) {
'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.')); 'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.'));
} }
// Space on the Sia account: "12.4 GiB of 46.6 GiB used · 34.2 GiB free" and a bar.
function spaceLine() {
const el = h('div', { class: 'space', hidden: true });
api('GET', '/api/space').then((s) => {
if (!s?.available || !(s.maxPinnedData > 0)) return;
const pct = Math.min(100, Math.round((s.pinnedData / s.maxPinnedData) * 100));
put(el, h('div', { class: 'small muted' }, `${fmtBytes(s.pinnedData)} of ${fmtBytes(s.maxPinnedData)} used on Sia · `, h('strong', {}, `${fmtBytes(s.remainingStorage)} free`)),
h('div', { class: `bar space-bar${pct >= 90 ? ' full' : ''}`, title: `${pct}% used` }, h('i', { style: `width:${Math.max(pct, s.pinnedData > 0 ? 1 : 0)}%` })));
el.hidden = false;
}).catch(() => {});
return el;
}
// "Sia account: …" line for page headers; fills itself in. // "Sia account: …" line for page headers; fills itself in.
function accountLine() { function accountLine() {
const el = h('div', { class: 'small muted account-line' }, 'Sia account: …'); const el = h('div', { class: 'small muted account-line' }, 'Sia account: …');
@ -1293,7 +1307,7 @@ function accountLine() {
async function bucketList(head, body, user, userSel) { async function bucketList(head, body, user, userSel) {
put(head, put(head,
h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()), h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine(), spaceLine()),
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive'))); h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive')));
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
const bar = h('div', { class: 'view-bar' }); const bar = h('div', { class: 'view-bar' });
@ -1314,7 +1328,7 @@ async function bucketList(head, body, user, userSel) {
put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null); put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null);
if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; }
const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`); const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`);
const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Delete', () => remove(b.name), 'danger']]); const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Rename…', () => renameDrive(b.name)], ['Delete', () => remove(b.name), 'danger']]);
if (mode === 'details') { if (mode === 'details') {
put(card, h('table', { class: 'files' }, put(card, h('table', { class: 'files' },
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))), h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))),
@ -1364,6 +1378,17 @@ async function bucketList(head, body, user, userSel) {
try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); } try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
} }
async function renameDrive(current) {
const input = h('input', { type: 'text', required: true, value: current, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', spellcheck: 'false' });
const name = await modal('Rename drive', h('div', { class: 'stack' },
h('label', { class: 'field' }, h('span', {}, 'New name'), input, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens.')),
h('p', { class: 'small muted', style: 'margin:0' }, 'Files and access settings move to the new name; nothing is uploaded again. S3 apps that use this drive need the new name.')),
[{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]);
if (!name || name === current) return;
try { const r = await api('POST', `${base}/${encodeURIComponent(current)}/rename`, { name }); toast(`Renamed to ${name}${r.moved ? ` (${r.moved} file${r.moved === 1 ? '' : 's'})` : ''}`); load(); }
catch (e) { fail(e); }
}
async function remove(name) { async function remove(name) {
if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return; if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return;
try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); } try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
@ -1452,8 +1477,8 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
} }
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
const fileName = (o) => o.key.slice(prefix.length); const fileName = (o) => o.key.slice(prefix.length);
const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Delete', () => deleteFolder(f), 'danger']]); const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]);
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Rename…', () => renameItem(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'));
const stop = (e) => e.stopPropagation(); const stop = (e) => e.stopPropagation();
// Narrow screens: Download and Share as icons beside the ⋯ menu. // Narrow screens: Download and Share as icons beside the ⋯ menu.
@ -1613,7 +1638,20 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
} 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.')); } 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); } } 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)}`)), 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 }]); [isHtml(o.key) && { label: 'Open as page ↗', value: () => { openPage(o.key); return false; } },
{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }].filter(Boolean));
}
// An HTML file as a web page, in a new tab. The server sandboxes it, so its
// scripts run without any access to Pithos; files beside it (CSS, images,
// other pages) load as they would from a website.
async function openPage(key) {
try {
const { path } = await api('POST', `${base}/page`, { key });
if (bridge) { await bridge.invoke('open-page', { path }); return; }
const w = window.open(path, '_blank', 'noopener');
if (!w && !/^(desktop)$/.test(state.status?.host)) toast('Allow pop-ups for Pithos to open pages in a new tab', 'error');
} catch (e) { fail(e); }
} }
// Music plays in the docked player, on through this folder's other tracks. // Music plays in the docked player, on through this folder's other tracks.
@ -1627,6 +1665,25 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key))); player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key)));
} }
// Rename a file or folder in place (a server-side copy: quick, no new upload).
async function renameItem(key) {
const folder = key.endsWith('/');
const current = folder ? key.slice(0, -1).split('/').pop() : key.split('/').pop();
const input = h('input', { type: 'text', required: true, value: current, pattern: '[^/]+', spellcheck: 'false' });
// Select the name without its extension, as file managers do.
setTimeout(() => { if (!folder && current.includes('.')) input.setSelectionRange(0, current.lastIndexOf('.')); else input.select(); }, 30);
const name = await modal(folder ? 'Rename folder' : 'Rename file', h('div', { class: 'stack' },
h('label', { class: 'field' }, h('span', {}, 'New name'), input),
folder && h('p', { class: 'small muted', style: 'margin:0' }, 'Everything inside moves with it. This is quick: the files stay where they are on Sia.')),
[{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]);
if (!name || name === current) return;
try {
if (folder) { const r = await api('POST', `${base}/rename-prefix`, { from: key, name }); toast(`Renamed to ${name}${r.moved ? ` (${r.moved} item${r.moved === 1 ? '' : 's'})` : ''}`); }
else { await api('POST', `${base}/rename-object`, { from: key, name }); toast(`Renamed to ${name}`); }
load();
} catch (e) { fail(e); }
}
async function deleteObject(key) { async function deleteObject(key) {
if (!(await confirmModal('Delete object?', key))) return; if (!(await confirmModal('Delete object?', key))) return;
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); }