From 001fce02e72e66c2ecbe9a6cb60e62f7017f6849 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 04:06:04 +0200 Subject: [PATCH] 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); }