From 001fce02e72e66c2ecbe9a6cb60e62f7017f6849 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 04:06:04 +0200 Subject: [PATCH 1/2] Theseus: bundle Pithos 0.3.12 New installs carry the same Pithos the extension channel serves. --- bundled-addons/pithos/addon.json | 2 +- bundled-addons/pithos/core/hosted-routes.js | 1 + bundled-addons/pithos/core/hosted.js | 49 ++++++- bundled-addons/pithos/core/s3.js | 33 +++++ bundled-addons/pithos/core/server.js | 145 +++++++++++++++++++- bundled-addons/pithos/core/space.js | 29 ++++ bundled-addons/pithos/index.js | 9 ++ bundled-addons/pithos/ui/app.css | 5 + bundled-addons/pithos/ui/app.js | 69 +++++++++- 9 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 bundled-addons/pithos/core/space.js diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 658bfe1e..6b4d703b 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "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.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/core/hosted-routes.js b/bundled-addons/pithos/core/hosted-routes.js index e28b486a..29fa733c 100644 --- a/bundled-addons/pithos/core/hosted-routes.js +++ b/bundled-addons/pithos/core/hosted-routes.js @@ -27,6 +27,7 @@ const FORWARD = [ ['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/], ['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/], ['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 // on the server. diff --git a/bundled-addons/pithos/core/hosted.js b/bundled-addons/pithos/core/hosted.js index cb0f12a6..a61cabd3 100644 --- a/bundled-addons/pithos/core/hosted.js +++ b/bundled-addons/pithos/core/hosted.js @@ -12,7 +12,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { S3Client, readBody, objectPath } from './s3.js'; +import { S3Client, S3Error, readBody, objectPath } from './s3.js'; import { driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN, } from './crypt.js'; @@ -124,7 +124,8 @@ export function plainCovered(rules, bucket, key) { } 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', 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', @@ -270,6 +271,50 @@ export class EncryptingS3Client { 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) { const dk = await this.dk(bucket); const ek = encryptPath(dk, key); diff --git a/bundled-addons/pithos/core/s3.js b/bundled-addons/pithos/core/s3.js index 31ce2261..ebba0041 100644 --- a/bundled-addons/pithos/core/s3.js +++ b/bundled-addons/pithos/core/s3.js @@ -136,6 +136,39 @@ export class S3Client { 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. async deletePrefix(bucket, prefix) { let n = 0; diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 7fb77090..1e86672b 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -26,6 +26,8 @@ import { readPrefs, writePrefs } from './prefs.js'; import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js'; import { installHosted } from './hosted-routes.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 UI_DIR = path.join(HERE, '..', 'ui'); @@ -408,6 +410,75 @@ export async function createPithos(opts = {}) { if (!prefix) throw new HttpError(400, 'prefix required'); 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: "", 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: "/", 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 // "/" is the usual marker that keeps an empty folder visible. route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => { @@ -418,6 +489,68 @@ export async function createPithos(opts = {}) { return { prefix }; }); // 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) => { const up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' }); const body = await readBody(up); @@ -536,8 +669,18 @@ export async function createPithos(opts = {}) { url = new URL(t.path, url); viaTicket = true; } - res.setHeader('x-frame-options', 'DENY'); 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 { // Session bootstrap: ?t= on any page swaps the secret for a cookie. diff --git a/bundled-addons/pithos/core/space.js b/bundled-addons/pithos/core/space.js new file mode 100644 index 00000000..68f6b78e --- /dev/null +++ b/bundled-addons/pithos/core/space.js @@ -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, + }; +} diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index 7d15085d..16af1220 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -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///… in an ordinary tab. // An older Theseus without site routes gets the loopback address. api.onMessage("open-in-tab", async ({ path: appPath } = {}) => { diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index 8a576d70..bf119f44 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -416,3 +416,8 @@ body.has-foot.has-player .toasts { bottom: 112px; } /* "More options" disclosure in dialogs */ details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; } 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); } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 0bc01ad8..f84a0c4e 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -284,7 +284,7 @@ function overview(main) { put(daemonCard, 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('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}`)), 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')), @@ -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'; return null; } +const isHtml = (key) => ['html', 'htm'].includes(EXT(key)); function suggestBucket(name) { let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); 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.')); } +// 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. function accountLine() { const el = h('div', { class: 'small muted account-line' }, 'Sia account: …'); @@ -1293,7 +1307,7 @@ function accountLine() { async function bucketList(head, body, user, userSel) { 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'))); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); 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); 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 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') { put(card, h('table', { class: 'files' }, 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); } } + 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) { 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); } @@ -1452,8 +1477,8 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); 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 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 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' : 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 stop = (e) => e.stopPropagation(); // 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.')); } 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 }]); + [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. @@ -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))); } + // 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) { if (!(await confirmModal('Delete object?', key))) return; try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } From b9ffe7e1a05321c94b2ab9ec1e6a88dbc3c10d16 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 04:10:50 +0200 Subject: [PATCH 2/2] Theseus Settings: the vault notes describe the vault that exists Privacy still said there is no persistent password manager and pointed to Bitwarden or KeePass, and Passwords said autofill was "phase 2". Both contradicted the encrypted vault and the address-bar fill button Theseus has had for a while. --- settings.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/settings.html b/settings.html index d43477be..3e2a276d 100644 --- a/settings.html +++ b/settings.html @@ -621,7 +621,7 @@
-
There is no autofill yet (phase 2). Copy the password from an entry and paste it into the site.
+
On a site with a saved login, a key appears in the address bar while the vault is unlocked: click it to fill the login.
@@ -818,7 +818,7 @@
-
There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).
+
These switches never touch the password vault: logins saved in Settings › Passwords stay in its encrypted vault.