Pithos: rename drives, folders and files; show free space on Sia

S3 has no rename, so files, folders and drives kept the name they were
made with. s3d copies objects in its own database (the copy points at
the same data on Sia), so a rename is a copy to the new name and a
delete of the old: quick, and it uses no extra storage. A drive rename
makes a new bucket, moves every file, rebuilds public access for the new
name (a policy names its bucket) and removes the old one. Names that
already exist are refused. Drives on Silent Mode encrypt with keys tied
to each name, so their client gets no rename until it can re-encrypt.

The drives page and Overview now show how much of the Sia account is
used and how much is free. s3d does not report it; the indexer's
GET /account does, signed with the app key s3d registered (read from its
database for this one request, BLAKE2b-256 + Ed25519 as indexd expects).
This commit is contained in:
Local Dev 2026-10-04 03:58:21 +02:00
parent 2d9b0a7a5e
commit 05a1941aa3
6 changed files with 293 additions and 5 deletions

View file

@ -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;

View file

@ -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: "<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 () => {
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
// "<folder>/" is the usual marker that keeps an empty folder visible.
route('PUT', '/api/s3/:user/buckets/:bucket/folder', async (req, url, p) => {

109
core/space.js Normal file
View file

@ -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=<unix expiry>&sc=<pubkey>&ss=<signature> (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 };
}

29
test/space.test.js Normal file
View file

@ -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'))));
});

View file

@ -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); }

View file

@ -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); }