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.
This commit is contained in:
Local Dev 2026-10-07 00:13:13 +02:00
parent df2afcf5df
commit ff79593471
3 changed files with 49 additions and 2 deletions

View file

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

View file

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

View file

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