From ff795934717d4339e170380bfe3534d2285d6ea2 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 7 Oct 2026 00:13:13 +0200 Subject: [PATCH] Pithos: rename S3 users s3d has no rename command, so a user named once kept that name. A user is one row in s3d's database (id, name) and keys and drives point at the id, so Pithos stops s3d (gently, after any upload), changes the name in place, updates its own public-link records and starts s3d again: access keys, secrets and drives stay as they are and S3 apps keep working. Checked against s3d v0.2.0: the old key still lists the same drives, taken and invalid names are refused. --- core/cli.js | 2 +- core/server.js | 31 ++++++++++++++++++++++++++++++- ui/app.js | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/core/cli.js b/core/cli.js index 0a62edd..01be265 100644 --- a/core/cli.js +++ b/core/cli.js @@ -86,7 +86,7 @@ export function makeCli(daemon) { // Users are passed as argv, never through a shell, but a leading '-' would be // read as a flag by s3d's parser. -function validateName(name) { +export function validateName(name) { if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(name)) { throw new CliError('user names are 1-64 characters: letters, digits, . _ @ -, not starting with a symbol'); } diff --git a/core/server.js b/core/server.js index 7bf05d3..81572d3 100644 --- a/core/server.js +++ b/core/server.js @@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url'; import { resolveConfigPath, resolveBinary } from './paths.js'; import { readConfig, writeConfig, ensureConfig, EDITABLE } from './config.js'; import { Daemon } from './daemon.js'; -import { makeCli, CliError } from './cli.js'; +import { makeCli, CliError, validateName } from './cli.js'; import { LoginSession } from './login.js'; import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js'; import * as admin from './admin.js'; @@ -293,6 +293,35 @@ export async function createPithos(opts = {}) { return { name, key, bucket: created, bucketError }; }); route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; }); + // s3d has no rename. A user is one row (id, name); keys and drives point at + // the id, so with s3d stopped the name is changed in place: keys, secrets + // and drives stay as they are, and S3 apps keep working. + route('POST', '/api/users/:name/rename', async (req, url, p) => { + if (hosted.isHosted()) throw new HttpError(409, 'Users on Silent Mode cannot be renamed yet.'); + const { name } = await jsonBody(req); + try { validateName(p.name); validateName(name); } catch (e) { throw new HttpError(400, e.message); } + if (name === p.name) return { name }; + await accountGuard(); + const file = path.join(cfg().directory, 's3d.db'); + if (!fs.existsSync(file)) throw new HttpError(409, 's3d has no database yet'); + const wasRunning = !!daemon.child; + if (wasRunning) await daemon.stop(); + try { + const { DatabaseSync } = await import('node:sqlite'); + const db = new DatabaseSync(file); + try { + if (db.prepare('SELECT 1 FROM users WHERE name = ?').get(name)) throw new HttpError(409, `a user named ${name} already exists`); + if (!db.prepare('UPDATE users SET name = ? WHERE name = ?').run(name, p.name).changes) throw new HttpError(404, `no user named ${p.name}`); + } finally { db.close(); } + // Pithos's own records that name the user + const links = readSiaLinks(); + if (links.some((l) => l.user === p.name)) writeSiaLinks(links.map((l) => (l.user === p.name ? { ...l, user: name } : l))); + } finally { + if (wasRunning) { try { daemon.start(); } catch (e) { daemon.pushLog('sys', `could not start s3d: ${e.message}`); } } + } + daemon.pushLog('sys', `renamed user ${p.name} to ${name}`); + return { name }; + }); route('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined)); route('POST', '/api/keys', async (req) => { const { user, accessKey, secretKey } = await jsonBody(req); diff --git a/ui/app.js b/ui/app.js index ea91c6a..bd71797 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1034,6 +1034,8 @@ function users(main) { ' ', h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), ' ', + !isHosted() && h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); renameUser(u.name); } }, 'Rename…'), + ' ', h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteUser(u.name); } }, 'Delete'))); if (!open) return [main]; const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' }, @@ -1081,6 +1083,22 @@ function users(main) { } catch (e) { fail(e); } } + async function renameUser(current) { + const input = h('input', { type: 'text', required: true, value: current, pattern: '[A-Za-z0-9][A-Za-z0-9._@\\-]{0,63}', spellcheck: 'false', autocomplete: 'off' }); + const name = await modal('Rename user', h('div', { class: 'stack' }, + h('label', { class: 'field' }, h('span', {}, 'New name'), input, h('small', {}, '1-64 characters: letters, digits, . _ @ -, not starting with a symbol.')), + h('p', { class: 'small muted', style: 'margin:0' }, 'Access keys and secrets stay the same, so S3 apps keep working, and the drives stay with this user. s3d stops for a moment while the name changes (after any upload in progress) and starts again.')), + [{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]); + if (!name || name === current) return; + try { + await api('POST', `/api/users/${encodeURIComponent(current)}/rename`, { name }); + if (state.selectedUser === current) { state.selectedUser = name; try { localStorage.setItem('pithos.user', name); } catch {} } + expanded.delete(current); + toast(`Renamed to ${name}`); + load(); + } catch (e) { fail(e); load(); } + } + async function deleteUser(name) { if (!(await confirmModal(`Delete ${name}?`, 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.'))) return; try { await api('DELETE', `/api/users/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }