diff --git a/core/s3.js b/core/s3.js index 31ce226..ebba004 100644 --- a/core/s3.js +++ b/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/core/server.js b/core/server.js index c38494d..de80307 100644 --- a/core/server.js +++ b/core/server.js @@ -27,6 +27,7 @@ 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'); @@ -409,6 +410,74 @@ 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. + // Drives on Silent Mode are encrypted with keys tied to each name; their + // client has no rename yet, and says so instead of breaking files. + 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 () => { + if (hosted.isHosted()) return { available: false, reason: 'hosted' }; + 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) => { diff --git a/core/space.js b/core/space.js new file mode 100644 index 0000000..9c48240 --- /dev/null +++ b/core/space.js @@ -0,0 +1,109 @@ +// Storage left on the Sia account: the indexer's GET /account, signed with +// the app key s3d registered at login (it lives in s3d's own database, and +// is only read here to sign this one request; it is never sent anywhere). +// The signature scheme is indexd's (api/app/auth.go requestHash + client.go +// sign): BLAKE2b-256 over method, host, path and the expiry, Ed25519-signed, +// passed as ?sv=&sc=&ss= (URL-safe base64). +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +// ---- BLAKE2b-256 (RFC 7693) ---------------------------------------------- +// Node only ships blake2b512, and Sia hashes are the 256-bit variant, which +// is a different parameter block, not a truncation. Inputs here are tiny, +// so plain BigInt arithmetic is fine. +const M64 = (1n << 64n) - 1n; +const IV = [ + 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, + 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n, +]; +const SIGMA = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], +]; +const rotr = (x, n) => ((x >> BigInt(n)) | (x << BigInt(64 - n))) & M64; + +export function blake2b256(data) { + const outLen = 32; + const h = IV.slice(); + h[0] ^= 0x01010000n ^ BigInt(outLen); + const buf = Buffer.from(data); + const blocks = Math.max(1, Math.ceil(buf.length / 128)); + for (let b = 0; b < blocks; b++) { + const last = b === blocks - 1; + const block = Buffer.alloc(128); + buf.copy(block, 0, b * 128, Math.min(buf.length, (b + 1) * 128)); + const m = Array.from({ length: 16 }, (_, i) => block.readBigUInt64LE(i * 8)); + const t = BigInt(last ? buf.length : (b + 1) * 128); + const v = [...h, ...IV]; + v[12] ^= t & M64; + v[13] ^= t >> 64n; + if (last) v[14] ^= M64; + const G = (a, bb, c, d, x, y) => { + v[a] = (v[a] + v[bb] + x) & M64; v[d] = rotr(v[d] ^ v[a], 32); + v[c] = (v[c] + v[d]) & M64; v[bb] = rotr(v[bb] ^ v[c], 24); + v[a] = (v[a] + v[bb] + y) & M64; v[d] = rotr(v[d] ^ v[a], 16); + v[c] = (v[c] + v[d]) & M64; v[bb] = rotr(v[bb] ^ v[c], 63); + }; + for (let r = 0; r < 12; r++) { + const s = SIGMA[r % 10]; + G(0, 4, 8, 12, m[s[0]], m[s[1]]); G(1, 5, 9, 13, m[s[2]], m[s[3]]); + G(2, 6, 10, 14, m[s[4]], m[s[5]]); G(3, 7, 11, 15, m[s[6]], m[s[7]]); + G(0, 5, 10, 15, m[s[8]], m[s[9]]); G(1, 6, 11, 12, m[s[10]], m[s[11]]); + G(2, 7, 8, 13, m[s[12]], m[s[13]]); G(3, 4, 9, 14, m[s[14]], m[s[15]]); + } + for (let i = 0; i < 8; i++) h[i] ^= v[i] ^ v[i + 8]; + } + const out = Buffer.alloc(64); + h.forEach((x, i) => out.writeBigUInt64LE(x, i * 8)); + return out.subarray(0, outLen); +} + +// ---- indexd request signing --------------------------------------------------- +const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex'); + +export function signIndexerUrl(appKey, method, endpointUrl, validUntilSec) { + const u = new URL(endpointUrl); + const until = Buffer.alloc(8); + until.writeBigUInt64LE(BigInt(validUntilSec)); + const hash = blake2b256(Buffer.concat([Buffer.from(method), Buffer.from(u.host), Buffer.from(u.pathname), until])); + const seed = Buffer.from(appKey).subarray(0, 32); // a Sia private key is seed || public key + const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, seed]), format: 'der', type: 'pkcs8' }); + const pub = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(-32); + const sig = crypto.sign(null, hash, key); + const b64 = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_'); + u.searchParams.set('sv', String(validUntilSec)); + u.searchParams.set('sc', b64(pub)); + u.searchParams.set('ss', b64(sig)); + return u; +} + +// ---- s3d's app key ------------------------------------------------------------- +export async function readAppKey(dataDir) { + const file = path.join(dataDir, 's3d.db'); + if (!fs.existsSync(file)) return null; + let DatabaseSync; + try { ({ DatabaseSync } = await import('node:sqlite')); } catch { return null; } + const db = new DatabaseSync(file, { readOnly: true }); + try { + const row = db.prepare('SELECT app_key, indexer_url FROM global_settings LIMIT 1').get(); + if (!row || !row.app_key) return null; + return { appKey: Buffer.from(row.app_key), indexerUrl: String(row.indexer_url || '') }; + } finally { db.close(); } +} + +// { maxPinnedData, pinnedData, remainingStorage } in bytes, from the indexer. +export async function accountSpace(dataDir, { fetchImpl = fetch } = {}) { + const k = await readAppKey(dataDir); + if (!k || !k.indexerUrl) return null; + const base = k.indexerUrl.replace(/\/+$/, ''); + const url = signIndexerUrl(k.appKey, 'GET', `${base}/account`, Math.floor(Date.now() / 1000) + 120); + k.appKey.fill(0); + const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15_000) }); + if (!res.ok) throw new Error(`indexer answered ${res.status}: ${(await res.text()).slice(0, 200)}`); + const j = await res.json(); + return { maxPinnedData: Number(j.maxPinnedData), pinnedData: Number(j.pinnedData), remainingStorage: Number(j.remainingStorage), indexer: new URL(base).host }; +} diff --git a/test/space.test.js b/test/space.test.js new file mode 100644 index 0000000..70bfa8d --- /dev/null +++ b/test/space.test.js @@ -0,0 +1,29 @@ +// The indexer signs requests with BLAKE2b-256 + Ed25519 (indexd api/app); +// a wrong hash or key layout fails with "invalid signature" at the indexer. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { blake2b256, signIndexerUrl } from '../core/space.js'; + +test('BLAKE2b-256 matches the RFC 7693 / reference vectors', () => { + const h = (s) => blake2b256(Buffer.from(s)).toString('hex'); + assert.equal(h(''), '0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8'); + assert.equal(h('abc'), 'bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319'); + // multi-block inputs (reference: Python hashlib.blake2b(digest_size=32)) + assert.equal(h('a'.repeat(128)), 'ae2aa48507885c4c950fb809b2076f959cde9f8ea6da260d9a3587df33dac450'); + assert.equal(h('a'.repeat(129)), '2f64744a6de0d2c0b56e64cf6e29a5aaa255010d415d51c75ccc82f73dccd865'); +}); + +test('signed indexer URL carries sv/sc/ss and verifies against the app public key', () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + const seed = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(-32); + const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32); + const appKey = Buffer.concat([seed, pub]); // Sia layout: seed || public key + const u = signIndexerUrl(appKey, 'GET', 'https://sia.storage/account', 1_800_000_000); + const unb64 = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + assert.equal(u.searchParams.get('sv'), '1800000000'); + assert.deepEqual(unb64(u.searchParams.get('sc')), pub); + const until = Buffer.alloc(8); until.writeBigUInt64LE(1_800_000_000n); + const hash = blake2b256(Buffer.concat([Buffer.from('GET'), Buffer.from('sia.storage'), Buffer.from('/account'), until])); + assert.ok(crypto.verify(null, hash, publicKey, unb64(u.searchParams.get('ss')))); +}); diff --git a/ui/app.css b/ui/app.css index 8a576d7..bf119f4 100644 --- a/ui/app.css +++ b/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/ui/app.js b/ui/app.js index 664f4fc..f84a0c4 100644 --- a/ui/app.js +++ b/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')), @@ -1282,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: …'); @@ -1294,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' }); @@ -1315,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', {}))), @@ -1365,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); } @@ -1453,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' : 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)], ['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. @@ -1641,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); }