From 4ff75d312a080a41b946b50297588e78e0426ed7 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 22:49:48 +0200 Subject: [PATCH] Pithos 0.3.18: JSON key export, Linux desktop builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bundled-addons/pithos/addon.json | 2 +- bundled-addons/pithos/core/config.js | 4 ++- bundled-addons/pithos/core/paths.js | 28 ++++++++++++++++++-- bundled-addons/pithos/core/server.js | 5 +++- bundled-addons/pithos/index.js | 4 ++- bundled-addons/pithos/ui/app.js | 39 ++++++++++++++++++++++++++-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 4c5a8704..396b54a8 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "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.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/core/config.js b/bundled-addons/pithos/core/config.js index eef39cde..70747c78 100644 --- a/bundled-addons/pithos/core/config.js +++ b/bundled-addons/pithos/core/config.js @@ -5,7 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; 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 // file is left untouched. @@ -71,6 +71,8 @@ export function ensureConfig(file) { apiAddress: DEFAULTS.apiAddress, adminAddress: DEFAULTS.adminAddress, adminPassword: randomPassword(), + // s3d itself would fall back to /var/lib/s3d, which this user cannot write + ...(linuxUserMode() ? { directory: defaultDataDir() } : {}), }); } diff --git a/bundled-addons/pithos/core/paths.js b/bundled-addons/pithos/core/paths.js index f9fa995a..2717072e 100644 --- a/bundled-addons/pithos/core/paths.js +++ b/bundled-addons/pithos/core/paths.js @@ -6,11 +6,33 @@ import fs from 'node:fs'; import os from 'node:os'; 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() { switch (process.platform) { 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'); - default: return '/var/lib/s3d'; + default: return linuxUserMode() ? userDataDir() : '/var/lib/s3d'; } } @@ -20,7 +42,9 @@ export function configSearchPaths() { switch (process.platform) { case 'win32': 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; } diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 527fd9c6..49b6199c 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -166,6 +166,7 @@ export async function createPithos(opts = {}) { pithos: pithosVersion, // electron-builder's portable exe sets this; updates then fetch the portable build. portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE, + platform: process.platform, daemon: daemon.status(), external: await probeExternal(c), 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; 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}`); - 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 body = { latest: latest && { version: latest.version, date: latest.date || null, changes: String(latest.changes || '').slice(0, 600) } }; releaseCache = { at: Date.now(), body }; diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index 4fa090cf..759d5f12 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -328,7 +328,9 @@ module.exports = { const safe = String(name || "pithos-credentials.pithoskey").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120); const r = await dialog.showSaveDialog(BrowserWindow.getFocusedWindow() || undefined, { 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 }; fs.writeFileSync(r.filePath, String(text || ""), { mode: 0o600 }); diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index b3bee57a..65c9829a 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -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.'), secretRow('Access key ID', k.accessKeyId), 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'), tabBtns, pre, h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))), [{ 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) // 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 // the portable exe, in the browser. 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 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;