Pithos 0.3.23: rename S3 users
This commit is contained in:
parent
d162b745f7
commit
a33854a926
4 changed files with 50 additions and 3 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "pithos",
|
"id": "pithos",
|
||||||
"name": "Pithos",
|
"name": "Pithos",
|
||||||
"version": "0.3.22",
|
"version": "0.3.23",
|
||||||
"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.",
|
"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",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export function makeCli(daemon) {
|
||||||
|
|
||||||
// Users are passed as argv, never through a shell, but a leading '-' would be
|
// Users are passed as argv, never through a shell, but a leading '-' would be
|
||||||
// read as a flag by s3d's parser.
|
// 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)) {
|
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');
|
throw new CliError('user names are 1-64 characters: letters, digits, . _ @ -, not starting with a symbol');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { resolveConfigPath, resolveBinary } from './paths.js';
|
import { resolveConfigPath, resolveBinary } from './paths.js';
|
||||||
import { readConfig, writeConfig, ensureConfig, EDITABLE } from './config.js';
|
import { readConfig, writeConfig, ensureConfig, EDITABLE } from './config.js';
|
||||||
import { Daemon } from './daemon.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 { LoginSession } from './login.js';
|
||||||
import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js';
|
import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
|
|
@ -293,6 +293,35 @@ export async function createPithos(opts = {}) {
|
||||||
return { name, key, bucket: created, bucketError };
|
return { name, key, bucket: created, bucketError };
|
||||||
});
|
});
|
||||||
route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; });
|
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('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined));
|
||||||
route('POST', '/api/keys', async (req) => {
|
route('POST', '/api/keys', async (req) => {
|
||||||
const { user, accessKey, secretKey } = await jsonBody(req);
|
const { user, accessKey, secretKey } = await jsonBody(req);
|
||||||
|
|
|
||||||
|
|
@ -1034,6 +1034,8 @@ function users(main) {
|
||||||
' ',
|
' ',
|
||||||
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'),
|
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')));
|
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteUser(u.name); } }, 'Delete')));
|
||||||
if (!open) return [main];
|
if (!open) return [main];
|
||||||
const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' },
|
const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' },
|
||||||
|
|
@ -1081,6 +1083,22 @@ function users(main) {
|
||||||
} catch (e) { fail(e); }
|
} 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) {
|
async function deleteUser(name) {
|
||||||
if (!(await confirmModal(`Delete ${name}?`, 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.'))) return;
|
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); }
|
try { await api('DELETE', `/api/users/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue