Pithos 0.3.18: JSON key export, Linux desktop builds

Export JSON… gives scripts and agents one file with endpoint, key, secret
and drive. The desktop app now ships for Linux too (AppImage, .deb,
tar.gz), using per-user s3d paths there.
This commit is contained in:
Local Dev 2026-10-04 22:49:48 +02:00
parent 29ab1a5b9b
commit 4ff75d312a
6 changed files with 74 additions and 8 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pithos", "id": "pithos",
"name": "Pithos", "name": "Pithos",
"version": "0.3.17", "version": "0.3.18",
"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=",

View file

@ -5,7 +5,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import YAML from '../vendor/yaml/lib/index.js'; import YAML from '../vendor/yaml/lib/index.js';
import { defaultDataDir } from './paths.js'; import { defaultDataDir, linuxUserMode } from './paths.js';
// Fields the settings form exposes, as dotted yaml paths. Anything else in the // Fields the settings form exposes, as dotted yaml paths. Anything else in the
// file is left untouched. // file is left untouched.
@ -71,6 +71,8 @@ export function ensureConfig(file) {
apiAddress: DEFAULTS.apiAddress, apiAddress: DEFAULTS.apiAddress,
adminAddress: DEFAULTS.adminAddress, adminAddress: DEFAULTS.adminAddress,
adminPassword: randomPassword(), adminPassword: randomPassword(),
// s3d itself would fall back to /var/lib/s3d, which this user cannot write
...(linuxUserMode() ? { directory: defaultDataDir() } : {}),
}); });
} }

View file

@ -6,11 +6,33 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
// s3d's Linux defaults (/etc/s3d, /var/lib/s3d) suit a system service. A
// desktop user who cannot write there, and has no system config, gets the
// XDG per-user places instead; new configs then name that directory
// explicitly, so a hand-run s3d with S3D_CONFIG_FILE agrees.
const xdg = (envName, fallback) => process.env[envName] || path.join(os.homedir(), ...fallback);
const userConfigFile = () => path.join(xdg('XDG_CONFIG_HOME', ['.config']), 's3d', 's3d.yml');
const userDataDir = () => path.join(xdg('XDG_DATA_HOME', ['.local', 'share']), 's3d');
function writable(p) {
// the nearest existing folder decides whether p could be created
for (let d = p; ; d = path.dirname(d)) {
if (fs.existsSync(d)) { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } }
if (path.dirname(d) === d) return false;
}
}
export function linuxUserMode() {
if (process.platform === 'win32' || process.platform === 'darwin') return false;
if (['/etc/s3d/s3d.yml', '/var/lib/s3d/s3d.yml'].some((p) => fs.existsSync(p))) return false;
return !(writable('/etc/s3d') && writable('/var/lib/s3d'));
}
export function defaultDataDir() { export function defaultDataDir() {
switch (process.platform) { switch (process.platform) {
case 'win32': return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 's3d'); case 'win32': return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 's3d');
case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', 's3d'); case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', 's3d');
default: return '/var/lib/s3d'; default: return linuxUserMode() ? userDataDir() : '/var/lib/s3d';
} }
} }
@ -20,7 +42,9 @@ export function configSearchPaths() {
switch (process.platform) { switch (process.platform) {
case 'win32': paths.push(path.join(defaultDataDir(), 's3d.yml')); break; case 'win32': paths.push(path.join(defaultDataDir(), 's3d.yml')); break;
case 'darwin': paths.push(path.join(defaultDataDir(), 's3d.yml')); break; case 'darwin': paths.push(path.join(defaultDataDir(), 's3d.yml')); break;
default: paths.push('/etc/s3d/s3d.yml', '/var/lib/s3d/s3d.yml'); default:
if (linuxUserMode()) paths.push(userConfigFile());
paths.push('/etc/s3d/s3d.yml', '/var/lib/s3d/s3d.yml');
} }
return paths; return paths;
} }

View file

@ -166,6 +166,7 @@ export async function createPithos(opts = {}) {
pithos: pithosVersion, pithos: pithosVersion,
// electron-builder's portable exe sets this; updates then fetch the portable build. // electron-builder's portable exe sets this; updates then fetch the portable build.
portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE, portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE,
platform: process.platform,
daemon: daemon.status(), daemon: daemon.status(),
external: await probeExternal(c), external: await probeExternal(c),
s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) }, s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) },
@ -540,7 +541,9 @@ export async function createPithos(opts = {}) {
if (releaseCache && Date.now() - releaseCache.at < 10 * 60_000 && url.searchParams.get('fresh') !== '1') return releaseCache.body; if (releaseCache && Date.now() - releaseCache.at < 10 * 60_000 && url.searchParams.get('fresh') !== '1') return releaseCache.body;
const res = await fetch(`${RELEASES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store', signal: AbortSignal.timeout(15_000) }).catch((e) => { throw new HttpError(502, e.message); }); const res = await fetch(`${RELEASES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store', signal: AbortSignal.timeout(15_000) }).catch((e) => { throw new HttpError(502, e.message); });
if (!res.ok) throw new HttpError(502, `the release list answered ${res.status}`); if (!res.ok) throw new HttpError(502, `the release list answered ${res.status}`);
const list = ((await res.json())?.releases || []).filter((e) => e.id === 'pithos-desktop' && /^\d+\.\d+\.\d+$/.test(e.version)); // each build is listed per platform ("win-x64", "linux-x64", "mac-…"); only ours counts
const os = { win32: 'win', linux: 'linux', darwin: 'mac' }[process.platform];
const list = ((await res.json())?.releases || []).filter((e) => e.id === 'pithos-desktop' && /^\d+\.\d+\.\d+$/.test(e.version) && String(e.platform || '').startsWith(`${os}-`));
const latest = list.reduce((best, e) => (!best || cmpVersion(e.version, best.version) > 0 ? e : best), null); const latest = list.reduce((best, e) => (!best || cmpVersion(e.version, best.version) > 0 ? e : best), null);
const body = { latest: latest && { version: latest.version, date: latest.date || null, changes: String(latest.changes || '').slice(0, 600) } }; const body = { latest: latest && { version: latest.version, date: latest.date || null, changes: String(latest.changes || '').slice(0, 600) } };
releaseCache = { at: Date.now(), body }; releaseCache = { at: Date.now(), body };

View file

@ -328,7 +328,9 @@ module.exports = {
const safe = String(name || "pithos-credentials.pithoskey").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120); const safe = String(name || "pithos-credentials.pithoskey").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120);
const r = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow() || undefined, { const r = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow() || undefined, {
defaultPath: path.join(app.getPath("downloads"), safe), defaultPath: path.join(app.getPath("downloads"), safe),
filters: [{ name: "Pithos credentials", extensions: ["pithoskey"] }], filters: safe.endsWith(".json")
? [{ name: "JSON", extensions: ["json"] }]
: [{ name: "Pithos credentials", extensions: ["pithoskey"] }],
}); });
if (r.canceled || !r.filePath) return { saved: false }; if (r.canceled || !r.filePath) return { saved: false };
fs.writeFileSync(r.filePath, String(text || ""), { mode: 0o600 }); fs.writeFileSync(r.filePath, String(text || ""), { mode: 0o600 });

View file

@ -1093,13 +1093,47 @@ AWS_ENDPOINT_URL=${endpoint}`;
fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'), fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'),
secretRow('Access key ID', k.accessKeyId), secretRow('Access key ID', k.accessKeyId),
secretRow('Secret key', k.secretKey), secretRow('Secret key', k.secretKey),
h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')), h('div', { class: 'row' },
h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…'),
h('button', { class: 'btn small', type: 'button', onclick: () => exportJsonDialog(k) }, 'Export JSON…')),
h('h3', { style: 'margin-top:8px' }, 'Client config'), h('h3', { style: 'margin-top:8px' }, 'Client config'),
tabBtns, pre, tabBtns, pre,
h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))), h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))),
[{ label: 'Done', kind: 'primary', submit: true, result: true }]); [{ label: 'Done', kind: 'primary', submit: true, result: true }]);
} }
// ---------------------------------------------------------------- plain JSON export
// The shape scripts and agents read: one key, one drive. It is not encrypted,
// so the dialog says where it should and should not go.
async function exportJsonDialog(k) {
const endpoint = state.status?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000';
let drives = [];
try { drives = (await api('GET', `/api/s3/${encodeURIComponent(k.user)}/buckets`)).map((b) => b.name); } catch {}
const sel = h('select', {}, drives.length
? drives.map((n) => h('option', { value: n }, n))
: h('option', { value: '' }, '(no drives yet)'));
const json = () => JSON.stringify({ endpoint, accessKey: k.accessKeyId, secretKey: k.secretKey, bucket: sel.value }, null, 2) + '\n';
const masked = () => json().replace(k.secretKey, '•'.repeat(12));
const pre = h('pre', { class: 'snippet' }, masked());
sel.addEventListener('change', () => { pre.textContent = masked(); });
await modal('Export JSON', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, `A plain JSON file with the endpoint, this key and one drive of ${k.user}, for scripts, backup tools and AI agents. The key can reach every drive of ${k.user}; the bucket field only says which one to use.`),
drives.length > 0 && h('label', { class: 'field' }, h('span', {}, 'Drive'), sel),
pre,
h('div', { class: 'banner warn' }, h('span', {}, 'The secret is not encrypted in this file. Keep it out of git, chats and shared folders, and give an agent its own S3 user so you can revoke it alone. For a protected copy use Save credentials….')),
isHosted() && h('p', { class: 'small muted', style: 'margin:0' }, 'Drives on Silent Mode are encrypted by Pithos on your computer, so other tools see encrypted files except in folders you share.')),
[{ label: 'Cancel', result: null },
{ label: 'Copy', value: async () => { await copy(json(), 'JSON copied'); return false; } },
{ label: 'Save file', kind: 'primary', submit: true, value: async () => {
try {
const name = `pithos-${k.user}${sel.value ? '-' + sel.value : ''}.json`;
if (await saveFile(name, json())) toast('Saved ' + name);
return true;
} catch (e) { fail(e); return false; }
} }]);
}
// ---------------------------------------------------------------- saved credentials (encrypted file) // ---------------------------------------------------------------- saved credentials (encrypted file)
// A key pair saved to a file the user keeps. Password files use PBKDF2-SHA256 // A key pair saved to a file the user keeps. Password files use PBKDF2-SHA256
@ -2536,7 +2570,8 @@ const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates
// (read by the local server). The chip downloads the matching installer, or // (read by the local server). The chip downloads the matching installer, or
// the portable exe, in the browser. // the portable exe, in the browser.
function desktopUpdates(current, { ver, chip, check }) { function desktopUpdates(current, { ver, chip, check }) {
const file = state.status?.portable ? 'Pithos-portable.exe' : 'Pithos-Setup.exe'; const plat = state.status?.platform;
const file = plat === 'linux' ? 'Pithos.AppImage' : plat === 'darwin' ? 'Pithos.dmg' : state.status?.portable ? 'Pithos-portable.exe' : 'Pithos-Setup.exe';
const rest = () => { chip.hidden = true; ver.hidden = false; }; const rest = () => { chip.hidden = true; ver.hidden = false; };
const flash = (text, cls, title) => { const flash = (text, cls, title) => {
chip.hidden = false; ver.hidden = true; chip.className = `brandupd ${cls}`; chip.textContent = text; chip.title = title || ''; chip.disabled = true; chip.onclick = null; chip.hidden = false; ver.hidden = true; chip.className = `brandupd ${cls}`; chip.textContent = text; chip.title = title || ''; chip.disabled = true; chip.onclick = null;