commit 6b8eeaf82b1e485b6b0e83dca16d32baae580f65 Author: Local Dev Date: Sat Oct 3 19:02:50 2026 +0200 Pithos: a control panel for s3d, the Sia S3 gateway s3d is driven entirely by a CLI and a two-route admin API, which keeps Sia storage out of reach for anyone who does not live in a terminal. Pithos wraps it: install the pinned s3d release (checksum verified), start/stop and logs, indexer login, users and access keys, a bucket and object browser with share links and public-read policies, and the upload pipeline stats. One control server (core/) serves one UI (ui/), and three hosts run it: a web console, an Electron desktop window and a Theseus add-on, so a feature lands everywhere at once. The server binds loopback, rejects foreign Host headers, and signs views in with a one-time secret that becomes a SameSite=Strict cookie. s3d login reads the recovery phrase with term.ReadPassword, so it is driven through a pty, with a console window fallback where the pty module is not shipped. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..891680e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +bin/s3d +bin/s3d.exe +dist/ +.dev/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee6256c --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..532812c --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Pithos + +A control panel for [s3d](https://github.com/SiaFoundation/s3d), the Sia +Foundation's S3 gateway. s3d itself is managed through a CLI and a two-route +admin API; Pithos puts a UI over all of it: + +- **Gateway**: install the pinned s3d release (SHA-256 checked), start, stop, + restart, live logs, edit `s3d.yml`. +- **Sia connection**: register s3d with an indexer (sia.storage or your own + indexd) with an existing recovery phrase or a freshly generated one, and + approve the connection in the browser. +- **Users & keys**: create and delete users, issue and revoke access keys, + copy ready-made `aws`, `rclone` and env-var config. +- **Buckets**: browse as any user, create and delete buckets, upload by + drag-and-drop, download, delete files and folders, presigned share links, + public-read policies. +- **Upload pipeline**: pending, uploaded, unpinned and failed objects from + `/stats/uploads`, plus "flush now". + +## One core, three hosts + +``` +core/ control server: s3d process, CLI wrapper, SigV4 S3 client, admin API, HTTP API +ui/ the single-page UI the server serves (plain JS, no build step) + +bin/pithos-web.js web console -> your browser +desktop/main.cjs desktop app -> an Electron window +theseus/ Theseus add-on -> a Theseus tab +``` + +Every host runs the same `createPithos()` server on a loopback port and +points a view at it. A feature added to `core/` + `ui/` lands in all three. + +### Web console + +```bash +npm install +node bin/pithos-web.js +``` + +It prints a one-time sign-in link for `127.0.0.1:8700`. To reach it from +another machine, set a password and put a TLS reverse proxy in front: + +```bash +PITHOS_PASSWORD='long random secret' node bin/pithos-web.js --listen 0.0.0.0:8700 --allow-host pithos.example.org +``` + +Other flags: `--config `, `--s3d `, `--bin-dir `, +`--password-file `. + +### Desktop + +```bash +npm run desktop +``` + +The window signs in with the server's one-time secret; nothing else on the +machine can drive the API. Quitting stops s3d cleanly. Installed s3d builds +go to the app's user-data folder. + +### Theseus + +```bash +node scripts/build-theseus-addon.mjs +``` + +builds `dist/theseus-addon/pithos/` (about 0.9 MB). Copy it into +`TheseusNavigator/bundled-addons/` to bundle it, or publish it as a +community extension. The dock menu has *Open Pithos* and *Stop s3d*. + +The add-on leaves out the pty module (12 MB of native code), so on Windows +the indexer login runs in its own console window and Pithos picks up the +result when it closes. + +## Which s3d instance it manages + +The same one a hand-run `s3d` would use: `S3D_CONFIG_FILE` if set, otherwise +`%APPDATA%\s3d\s3d.yml` (Windows), `~/Library/Application Support/s3d/s3d.yml` +(macOS), `/etc/s3d/s3d.yml` (Linux). Every s3d call Pithos makes gets +`S3D_CONFIG_FILE` pinned, so the daemon and the CLI calls always agree. + +If another s3d process is already serving that config (a service, or one +started by hand), the overview shows it as *external* and reads its stats, +but Pithos won't start a second copy. + +Pithos needs **s3d v0.2.0 or newer**: `users list` and `keys list` arrived +in 0.2.0. Older builds get an upgrade banner. + +## Security model + +- The server binds loopback by default and refuses Host headers other than + its own address (DNS-rebinding guard). Binding beyond loopback requires a + password of at least 12 characters. +- Sessions are HttpOnly, SameSite=Strict cookies. Every non-GET request also + needs an `x-pithos: 1` header, so a cross-site form cannot drive the API. +- The recovery phrase is only written to the s3d login process's terminal. + It is never stored, logged or sent anywhere else. A generated phrase is + shown once and must be acknowledged. +- Downloads are served with a sandboxing CSP and `nosniff`, so a stored HTML + object cannot run script on the console's origin. +- s3d keeps access-key secrets in plain text in its own SQLite database. + Pithos shows them to whoever is signed in to the console, so treat console + access as root on the gateway. + +## How it talks to s3d + +| Need | Mechanism | +| --- | --- | +| users, keys, version, registration check | `s3d users …`, `s3d keys …`, `s3d version`, `s3d login` with stdin closed | +| indexer login | `s3d login` in a pseudo-terminal (it reads the phrase with `term.ReadPassword`) | +| buckets and objects | the S3 API, signed with SigV4 as the chosen user's first access key | +| stats, flush | the admin API (`/stats/uploads`, `/objects/flush`) with the configured admin password | + +`test/sigv4.test.js` cross-checks the signer against the AWS CLI on awkward +keys and prefixes and on presigned URLs (`npm test`). + +## Limits and next steps + +- Uploads are single PUTs, capped at 5 GiB (s3d's part limit). Multipart + uploads from the browser would lift that. +- Object versioning, lifecycle rules and multipart-upload cleanup are not in + the UI yet. +- Desktop installers (electron-builder) are not set up yet. +- Multi-tenant mode, where end users sign in with a wallet and each get + their own s3d user and quota, is the planned follow-up. It is the + Hestia direction in `Hephaestus/DESIGN-hestia.md`. + +## License + +MPL-2.0. s3d is MIT, © The Sia Foundation. diff --git a/bin/pithos-web.js b/bin/pithos-web.js new file mode 100644 index 0000000..f369b09 --- /dev/null +++ b/bin/pithos-web.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Web console: serves the Pithos UI in a browser. +// +// pithos-web loopback only, prints a sign-in link +// pithos-web --listen 0.0.0.0:8700 remote access; requires PITHOS_PASSWORD +// (or --password-file) and a TLS proxy in front +// +// Flags: --listen host:port, --config , --s3d , --bin-dir , +// --password-file , --allow-host (repeatable) + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createPithos } from '../core/server.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +function parseArgs(argv) { + const out = { allowHosts: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => { if (i + 1 >= argv.length) fail(`${a} needs a value`); return argv[++i]; }; + switch (a) { + case '--listen': out.listen = next(); break; + case '--config': out.config = next(); break; + case '--s3d': out.s3d = next(); break; + case '--bin-dir': out.binDir = next(); break; + case '--password-file': out.passwordFile = next(); break; + case '--allow-host': out.allowHosts.push(next().toLowerCase()); break; + case '-h': case '--help': + console.log(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 11).map((l) => l.replace(/^\/\/ ?/, '')).join('\n')); + process.exit(0); + default: fail(`unknown flag ${a}`); + } + } + return out; +} + +function fail(msg) { console.error(`pithos-web: ${msg}`); process.exit(2); } + +const args = parseArgs(process.argv.slice(2)); +const [host, portStr] = splitHostPort(args.listen || '127.0.0.1:8700'); +const loopback = ['127.0.0.1', 'localhost', '::1'].includes(host); + +let password = process.env.PITHOS_PASSWORD || ''; +if (args.passwordFile) password = fs.readFileSync(args.passwordFile, 'utf8').trim(); +if (!loopback && password.length < 12) fail('listening beyond loopback needs PITHOS_PASSWORD or --password-file (12+ characters)'); + +const pithos = await createPithos({ + host, + port: Number(portStr), + configFile: args.config, + binary: args.s3d, + binDir: args.binDir || HERE, + password: password || undefined, + allowedHosts: args.allowHosts, + hostName: 'web', +}); + +console.log(`Pithos web console on ${pithos.url}`); +if (loopback) console.log(`Sign in: ${pithos.authUrl}`); +else console.log('Sign in with the configured password. Put a TLS reverse proxy in front of this port.'); +console.log(`s3d config: ${pithos.daemon.configFile}`); +console.log(`s3d binary: ${pithos.daemon.binary || '(not found - install it from the console)'}`); + +const shutdown = async () => { + console.log('stopping...'); + await pithos.close(); + process.exit(0); +}; +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +function splitHostPort(s) { + const m = s.match(/^\[?([^\]]+?)\]?:(\d+)$/); + if (!m) fail(`--listen wants host:port, got ${s}`); + return [m[1], m[2]]; +} diff --git a/core/admin.js b/core/admin.js new file mode 100644 index 0000000..892d547 --- /dev/null +++ b/core/admin.js @@ -0,0 +1,34 @@ +// s3d's admin API (Basic auth, empty user): upload stats, flush, snapshots. + +function authHeader(password) { + return 'Basic ' + Buffer.from(':' + password).toString('base64'); +} + +async function adminFetch(cfg, method, route, { timeout = 10_000 } = {}) { + if (!cfg.adminAddress || !cfg.adminPassword) throw new Error('adminAddress and adminPassword must be set in s3d.yml'); + const ctl = new AbortController(); + const timer = timeout ? setTimeout(() => ctl.abort(), timeout) : null; + try { + const res = await fetch(`http://${cfg.adminAddress}${route}`, { + method, headers: { authorization: authHeader(cfg.adminPassword) }, signal: ctl.signal, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`admin API ${route}: ${res.status} ${text.trim()}`); + return text; + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function uploadStats(cfg) { + return JSON.parse(await adminFetch(cfg, 'GET', '/stats/uploads')); +} + +// Blocks until every pending object is on Sia, so no timeout. +export async function flush(cfg) { + await adminFetch(cfg, 'POST', '/objects/flush', { timeout: 0 }); +} + +export async function snapshot(cfg) { + return adminFetch(cfg, 'POST', '/snapshots', { timeout: 0 }); +} diff --git a/core/binary.js b/core/binary.js new file mode 100644 index 0000000..1442088 --- /dev/null +++ b/core/binary.js @@ -0,0 +1,77 @@ +// Installs the pinned s3d release into Pithos's own bin folder. The digests are +// GitHub's published sha256 for each release asset; a download that does not +// match is discarded before anything is written to the bin folder. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import zlib from 'node:zlib'; + +export const S3D_VERSION = '0.2.0'; + +const ASSETS = { + 'win32-x64': { file: 's3d_windows_amd64.zip', sha256: '239356ef55228d03aa1889c7208357b83faf66947ade2cba4f7a3893c632c473' }, + 'linux-x64': { file: 's3d_linux_amd64.zip', sha256: 'e537d68be8b6749e3e239d572c497a4f1ebc81ef5a3b70d5ec3cc9ffa6498e73' }, + 'linux-arm64': { file: 's3d_linux_arm64.zip', sha256: '76848764f09922f31489c809514cdc928b3882836d6e5b64dcac085a84b4958b' }, + 'darwin-x64': { file: 's3d_darwin_amd64.zip', sha256: 'd7fe282efab35242b001ed9040d833334034682225b4c2b6b2eda67ecca8992e' }, + 'darwin-arm64': { file: 's3d_darwin_arm64.zip', sha256: '9eb6b73cb30b5b6b54fbbc583af6f7d9e56a30c55247ac70568918a1c98a3353' }, +}; + +export function assetForPlatform() { + return ASSETS[`${process.platform}-${process.arch}`] || null; +} + +export async function installS3d(binDir, onProgress = () => {}) { + const asset = assetForPlatform(); + if (!asset) throw new Error(`no s3d release for ${process.platform}-${process.arch}`); + const url = `https://github.com/SiaFoundation/s3d/releases/download/v${S3D_VERSION}/${asset.file}`; + onProgress({ phase: 'download', url }); + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`); + const zip = Buffer.from(await res.arrayBuffer()); + const digest = crypto.createHash('sha256').update(zip).digest('hex'); + if (digest !== asset.sha256) throw new Error(`checksum mismatch for ${asset.file} (got ${digest})`); + onProgress({ phase: 'extract' }); + + const exe = process.platform === 'win32' ? 's3d.exe' : 's3d'; + const data = extractFromZip(zip, (name) => path.basename(name) === exe); + if (!data) throw new Error(`${exe} not found in ${asset.file}`); + fs.mkdirSync(binDir, { recursive: true }); + const target = path.join(binDir, exe); + const tmp = target + '.new'; + fs.writeFileSync(tmp, data, { mode: 0o755 }); + fs.renameSync(tmp, target); + onProgress({ phase: 'done', path: target }); + return target; +} + +// Minimal zip reader: walks the central directory and inflates one entry. +function extractFromZip(buf, match) { + let eocd = -1; + for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) throw new Error('not a zip file'); + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + for (let n = 0; n < count; n++) { + if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('corrupt zip directory'); + const method = buf.readUInt16LE(p + 10); + const csize = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.toString('utf8', p + 46, p + 46 + nameLen); + p += 46 + nameLen + extraLen + commentLen; + if (!match(name)) continue; + const lName = buf.readUInt16LE(local + 26); + const lExtra = buf.readUInt16LE(local + 28); + const start = local + 30 + lName + lExtra; + const raw = buf.subarray(start, start + csize); + if (method === 0) return Buffer.from(raw); + if (method === 8) return zlib.inflateRawSync(raw); + throw new Error(`unsupported zip method ${method}`); + } + return null; +} diff --git a/core/cli.js b/core/cli.js new file mode 100644 index 0000000..0a62edd --- /dev/null +++ b/core/cli.js @@ -0,0 +1,93 @@ +// Typed wrappers over s3d's user/key subcommands. s3d has no HTTP API for +// these; the CLI opens the same SQLite file the daemon uses (WAL mode, so it +// is safe while the daemon is running). + +import { spawn } from 'node:child_process'; + +export class CliError extends Error {} + +function check({ code, stdout, stderr }) { + if (code !== 0) throw new CliError((stderr || stdout).trim() || `s3d exited with code ${code}`); + return stdout; +} + +export function makeCli(daemon) { + return { + // Whether this s3d holds an app key. `s3d login` reports an existing + // registration before it reads any input; with stdin closed an + // unregistered instance hits EOF at the first prompt and exits. + async registration() { + const { stdout } = await new Promise((resolve, reject) => { + const child = spawn(daemon.binary, ['login'], { + cwd: daemon.cwd(), env: daemon.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + }); + let out = ''; + child.stdout.on('data', (d) => { out += d; }); + child.stderr.on('data', (d) => { out += d; }); + const timer = setTimeout(() => child.kill(), 15_000); + child.on('error', reject); + child.on('exit', () => { clearTimeout(timer); resolve({ stdout: out.replace(/\x1b\[[0-9;]*m/g, '') }); }); + }); + const m = stdout.match(/already registered with (\S+?)\.?\s*$/m); + return m ? { registered: true, indexerUrl: m[1] } : { registered: false }; + }, + + async version() { + const out = check(await daemon.run(['version'])); + const m = out.match(/s3d v?(\S+)/); + const commit = out.match(/Commit:\s*(\S+)/); + return { version: m ? m[1] : null, commit: commit ? commit[1] : null, raw: out.trim() }; + }, + + async listUsers() { + const out = check(await daemon.run(['users', 'list'])); + if (/^No users found/m.test(out)) return []; + return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + }, + + async createUser(name) { + validateName(name); + check(await daemon.run(['users', 'create', name])); + }, + + async deleteUser(name) { + validateName(name); + check(await daemon.run(['users', 'delete', name])); + }, + + // [{ accessKeyId, secretKey, user }] + async listKeys(user) { + const out = check(await daemon.run(user ? ['keys', 'list', user] : ['keys', 'list'])); + if (/^No access keys found/m.test(out)) return []; + return out.split(/\r?\n/).filter(Boolean).map((line) => { + const [accessKeyId, secretKey, ...rest] = line.split('\t'); + return { accessKeyId, secretKey, user: rest.join('\t') }; + }); + }, + + async createKey(user, { accessKey, secretKey } = {}) { + validateName(user); + const args = ['keys', 'create']; + if (accessKey || secretKey) args.push('--access-key', accessKey || '', '--secret-key', secretKey || ''); + args.push(user); + const out = check(await daemon.run(args)); + const ak = out.match(/Access Key:\s*(\S+)/); + const sk = out.match(/Secret Key:\s*(\S+)/); + if (!ak || !sk) throw new CliError(`unexpected output from s3d keys create: ${out.trim()}`); + return { accessKeyId: ak[1], secretKey: sk[1], user }; + }, + + async deleteKey(accessKeyId) { + if (!/^[\x21-\x7e]{1,128}$/.test(accessKeyId)) throw new CliError('invalid access key id'); + check(await daemon.run(['keys', 'delete', accessKeyId])); + }, + }; +} + +// 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) { + 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/config.js b/core/config.js new file mode 100644 index 0000000..472917c --- /dev/null +++ b/core/config.js @@ -0,0 +1,79 @@ +// s3d.yml read/write. Edits go through the yaml Document API so keys the GUI +// does not know about (and the operator's comments) survive a save. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import YAML from 'yaml'; +import { defaultDataDir } from './paths.js'; + +// Fields the settings form exposes, as dotted yaml paths. Anything else in the +// file is left untouched. +export const EDITABLE = [ + 'apiAddress', 'apiHTTPSAddress', 'adminAddress', 'adminPassword', 'directory', + 'sia.diskUsageLimit', 'sia.uploadThreads', 'sia.dataShards', 'sia.parityShards', + 's3.hostBases', + 'log.stdout.enabled', 'log.stdout.level', 'log.file.enabled', 'log.file.level', +]; + +export const DEFAULTS = { + apiAddress: '127.0.0.1:8000', + adminAddress: '127.0.0.1:8001', +}; + +export function readConfig(file) { + const exists = fs.existsSync(file); + const doc = exists ? YAML.parseDocument(fs.readFileSync(file, 'utf8')) : new YAML.Document({}); + if (doc.errors.length) throw new Error(`${file}: ${doc.errors[0].message}`); + const data = doc.toJS() || {}; + return { + file, + exists, + data, + // Effective values the daemon will use, for things the GUI needs to reach it. + apiAddress: data.apiAddress || DEFAULTS.apiAddress, + adminAddress: data.adminAddress || DEFAULTS.adminAddress, + adminPassword: data.adminPassword || '', + directory: process.env.S3D_DATA_DIR || data.directory || defaultDataDir(), + }; +} + +// patch: { 'sia.uploadThreads': 8, 'apiHTTPSAddress': null, ... }; null/'' deletes the key. +export function writeConfig(file, patch) { + const exists = fs.existsSync(file); + const doc = exists ? YAML.parseDocument(fs.readFileSync(file, 'utf8')) : new YAML.Document({}); + if (doc.contents == null) doc.contents = doc.createNode({}); + for (const [key, value] of Object.entries(patch)) { + if (!EDITABLE.includes(key)) throw new Error(`not an editable setting: ${key}`); + const p = key.split('.'); + if (value === null || value === '' || (Array.isArray(value) && value.length === 0)) doc.deleteIn(p); + else doc.setIn(p, value); + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, doc.toString(), { mode: 0o600 }); + fs.renameSync(tmp, file); + return readConfig(file); +} + +// A fresh config the daemon can start with: admin API on, random password. +export function ensureConfig(file) { + if (fs.existsSync(file)) { + const cfg = readConfig(file); + if (cfg.data.adminAddress && cfg.data.adminPassword) return cfg; + // s3d refuses to start without both, and the GUI needs them for stats. + return writeConfig(file, { + adminAddress: cfg.data.adminAddress || DEFAULTS.adminAddress, + adminPassword: cfg.data.adminPassword || randomPassword(), + }); + } + return writeConfig(file, { + apiAddress: DEFAULTS.apiAddress, + adminAddress: DEFAULTS.adminAddress, + adminPassword: randomPassword(), + }); +} + +export function randomPassword() { + return crypto.randomBytes(18).toString('base64url'); +} diff --git a/core/daemon.js b/core/daemon.js new file mode 100644 index 0000000..6fd4235 --- /dev/null +++ b/core/daemon.js @@ -0,0 +1,117 @@ +// Owns the s3d process: start/stop, a log ring buffer, and one-shot CLI runs +// (users/keys/version) against the same config file the daemon uses. + +import { spawn, execFile } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; + +const LOG_LINES = 2000; +const ANSI = /\x1b\[[0-9;]*m/g; + +export class Daemon extends EventEmitter { + constructor({ binary, configFile }) { + super(); + this.binary = binary; + this.configFile = configFile; + this.child = null; + this.state = 'stopped'; // stopped | starting | running | stopping | crashed + this.exitCode = null; + this.startedAt = null; + this.logs = []; + this.seq = 0; + } + + env() { + // S3D_CONFIG_FILE pins every invocation to the config this GUI edits; the + // cwd is the config's folder because s3d also searches ./s3d.yml first. + return { ...process.env, S3D_CONFIG_FILE: this.configFile }; + } + + cwd() { return path.dirname(this.configFile); } + + status() { + return { + state: this.state, + pid: this.child?.pid ?? null, + exitCode: this.exitCode, + startedAt: this.startedAt, + binary: this.binary, + needsLogin: !!this.needsLogin, + configFile: this.configFile, + }; + } + + setState(state) { + this.state = state; + this.emit('status', this.status()); + } + + pushLog(stream, chunk) { + for (const raw of chunk.toString('utf8').split(/\r?\n/)) { + const line = raw.replace(ANSI, ''); + if (!line) continue; + const entry = { seq: ++this.seq, t: Date.now(), stream, line }; + // s3d logs "server started" once every listener is up. + if (this.state === 'starting' && /server started/.test(line)) this.setState('running'); + if (/No app key found/.test(line)) this.needsLogin = true; + this.logs.push(entry); + if (this.logs.length > LOG_LINES) this.logs.shift(); + this.emit('log', entry); + } + } + + logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); } + + start() { + if (!this.binary) throw new Error('s3d binary not found'); + if (this.child) return this.status(); + this.exitCode = null; + this.needsLogin = false; + this.setState('starting'); + const child = spawn(this.binary, [], { + cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + }); + this.child = child; + this.startedAt = Date.now(); + child.stdout.on('data', (d) => this.pushLog('out', d)); + child.stderr.on('data', (d) => this.pushLog('err', d)); + child.on('error', (err) => { + this.pushLog('err', `failed to start s3d: ${err.message}`); + // A spawn failure never emits 'exit'. + if (child.pid === undefined) { this.child = null; this.setState('crashed'); } + }); + child.on('exit', (code, signal) => { + this.child = null; + this.exitCode = code; + this.pushLog('sys', `s3d exited (${signal || `code ${code}`})`); + this.setState(this.state === 'stopping' ? 'stopped' : 'crashed'); + }); + return this.status(); + } + + async stop() { + const child = this.child; + if (!child) return this.status(); + this.setState('stopping'); + const exited = new Promise((r) => child.once('exit', r)); + child.kill(); // SIGTERM on unix; TerminateProcess on Windows + const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 10_000); + await exited; + clearTimeout(timer); + return this.status(); + } + + // One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a + // non-zero exit so callers can surface s3d's own error text. + run(args, { timeout = 30_000 } = {}) { + if (!this.binary) return Promise.reject(new Error('s3d binary not found')); + return new Promise((resolve, reject) => { + execFile(this.binary, args, { + cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20, + }, (err, stdout, stderr) => { + if (err && typeof err.code !== 'number') return reject(err); + resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') }); + }); + }); + } +} diff --git a/core/login.js b/core/login.js new file mode 100644 index 0000000..d60b198 --- /dev/null +++ b/core/login.js @@ -0,0 +1,185 @@ +// Drives `s3d login` through a pseudo-terminal. s3d reads the recovery phrase +// with term.ReadPassword, which needs a real console, so plain pipes fail. +// The phrase only ever lives in this process's memory: it is written to the +// pty and never logged or emitted except the one-time display of a phrase +// s3d generated, which the user has to copy down. + +import { EventEmitter } from 'node:events'; +import { spawn } from 'node:child_process'; + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g; + +let ptyModule; +async function loadPty() { + if (ptyModule === undefined) { + try { ptyModule = await import('@lydell/node-pty'); } catch { ptyModule = null; } + } + return ptyModule && (ptyModule.default || ptyModule); +} + +export class LoginSession extends EventEmitter { + // registration: async () => ({ registered, indexerUrl }), used to learn the + // outcome when login ran in a console window we cannot read. + constructor(daemon, { registration } = {}) { + super(); + this.daemon = daemon; + this.registration = registration; + this.term = null; + this.buffer = ''; + this.state = 'idle'; + // idle | indexer | phrase | confirm | approve | registering | done | already | failed + this.info = {}; + } + + snapshot() { return { state: this.state, ...this.info }; } + + set(state, info = {}) { + this.state = state; + Object.assign(this.info, info); + this.emit('state', this.snapshot()); + } + + async start({ indexerUrl, phrase }) { + if (this.term) throw new Error('a login is already in progress'); + this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' }; + this.info = {}; + const pty = await loadPty(); + if (!pty) return this.startInConsole(); + this.buffer = ''; + this.generated = null; + this.lastError = null; + const term = pty.spawn(this.daemon.binary, ['login'], { + name: 'xterm', cols: 200, rows: 50, cwd: this.daemon.cwd(), env: this.daemon.env(), + }); + this.term = term; + this.set('starting'); + // A cancelled session's pty can still flush output or exit after a new + // one started; only the current term may drive the state machine. + term.onData((d) => { + if (this.term !== term) return; + try { this.onData(d); } catch (e) { this.fail(e.message); } + }); + term.onExit(({ exitCode }) => { + if (this.term !== term) return; + this.term = null; + this.answers = null; + if (!['done', 'already'].includes(this.state)) { + this.set('failed', { error: this.lastError || `s3d login exited with code ${exitCode}` }); + } + }); + return this.snapshot(); + } + + // Fallback when the pty module is not shipped (the Theseus add-on): run + // s3d login in its own console window and check the result when it closes. + startInConsole() { + if (process.platform !== 'win32') { + throw new Error('run "s3d login" in a terminal on this machine, then reload this page'); + } + // detached gives a console app its own visible console window on Windows. + const child = spawn(this.daemon.binary, ['login'], { + cwd: this.daemon.cwd(), env: this.daemon.env(), detached: true, stdio: 'ignore', windowsHide: false, + }); + this.term = { kill: () => child.kill(), write() {} }; + const term = this.term; + this.answers = null; + this.set('terminal'); + child.on('error', (e) => { if (this.term === term) this.fail(e.message); }); + child.on('exit', async () => { + if (this.term !== term) return; + this.term = null; + try { + const r = await this.registration(); + if (r.registered) this.set('done', { indexerUrl: r.indexerUrl }); + else this.set('failed', { error: 'the login window closed before registration finished' }); + } catch (e) { this.set('failed', { error: e.message }); } + }); + return this.snapshot(); + } + + // The user confirms they stored a phrase s3d generated; we echo it back. + confirmGenerated() { + if (this.state !== 'confirm' || !this.generated) throw new Error('nothing to confirm'); + this.write(this.generated); + this.generated = null; + this.info.generatedPhrase = undefined; + this.set('approve-wait'); + } + + cancel() { + if (this.term) { try { this.term.kill(); } catch {} } + this.term = null; + this.answers = null; + this.generated = null; + this.set('idle', { error: undefined, approvalUrl: undefined, generatedPhrase: undefined }); + } + + write(s) { this.term?.write(s + '\r'); } + + onData(chunk) { + this.buffer += chunk.replace(ANSI, ''); + // Work line-by-line, but prompts have no trailing newline, so also look at + // the unterminated tail. + const text = this.buffer; + + // Checked first: s3d prints the error and re-prompts in the same chunk, + // and answering that prompt with a blank would make it generate a phrase. + const bad = text.match(/Invalid recovery phrase: (.+)$/m); + if (bad) { + // s3d would re-prompt forever; stop and let the user retype. + return this.fail(`invalid recovery phrase: ${bad[1].trim()}`); + } + if (/already registered with (\S+?)\.?\s*$/m.test(text)) { + const m = text.match(/already registered with (\S+?)\.?\s*$/m); + this.buffer = ''; + return this.set('already', { indexerUrl: m[1] }); + } + if (/Indexer URL \(default: [^)]*\):\s*$/.test(text)) { + this.buffer = ''; + this.set('indexer'); + return this.write(this.answers.indexerUrl); + } + if (/Recovery Phrase:\s*(.+)$/m.test(text) && !this.generated) { + // s3d generated a phrase because we sent a blank one. + this.generated = text.match(/Recovery Phrase:\s*(.+)$/m)[1].trim(); + } + if (/Confirm recovery phrase:\s*$/.test(text)) { + this.buffer = ''; + if (!this.generated) return this.fail('could not read the generated recovery phrase'); + return this.set('confirm', { generatedPhrase: this.generated }); + } + if (/Enter recovery phrase:\s*$/.test(text)) { + this.buffer = ''; + this.set('phrase'); + const p = this.answers.phrase; + this.answers.phrase = ''; + return this.write(p); + } + const url = text.match(/visiting the following URL:\s*(\S+)/); + if (url) { + this.buffer = text.slice(url.index + url[0].length); + return this.set('approve', { approvalUrl: url[1] }); + } + if (/Login successful/.test(text)) { + this.buffer = ''; + return this.set('done'); + } + if (/app connection was declined/.test(text)) { + this.buffer = ''; + return this.fail('the connection was declined on the indexer'); + } + const fatal = text.match(/^(failed to [^\n]+)$/m); + if (fatal) this.lastError = fatal[1].trim(); + // Keep the tail bounded; anything we care about is short. + if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-2048); + } + + fail(error) { + const term = this.term; + this.term = null; + this.answers = null; + this.generated = null; + this.set('failed', { error, generatedPhrase: undefined }); + if (term) { try { term.kill(); } catch {} } + } +} diff --git a/core/paths.js b/core/paths.js new file mode 100644 index 0000000..f9fa995 --- /dev/null +++ b/core/paths.js @@ -0,0 +1,49 @@ +// Where s3d keeps its config and data on each OS, mirroring the lookup in +// s3d's cmd/s3d/main.go (configSearchPaths / applicationDirectoryOS) so the +// GUI and a hand-run `s3d` always agree on which instance they are touching. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +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'; + } +} + +export function configSearchPaths() { + const paths = []; + if (process.env.S3D_DATA_DIR) paths.push(path.join(process.env.S3D_DATA_DIR, 's3d.yml')); + 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'); + } + return paths; +} + +// The config file this GUI manages: an explicit S3D_CONFIG_FILE wins, then the +// first existing search path, then the first search path (created on save). +export function resolveConfigPath(explicit) { + if (explicit) return path.resolve(explicit); + if (process.env.S3D_CONFIG_FILE) return path.resolve(process.env.S3D_CONFIG_FILE); + const candidates = configSearchPaths(); + return candidates.find((p) => fs.existsSync(p)) || candidates[0]; +} + +const EXE = process.platform === 'win32' ? 's3d.exe' : 's3d'; + +// Binary lookup: explicit path, S3D_BIN, the vendored copy next to Pithos, then PATH. +export function resolveBinary(explicit, vendorDir) { + const tries = [explicit, process.env.S3D_BIN, vendorDir && path.join(vendorDir, EXE)].filter(Boolean); + for (const p of tries) if (fs.existsSync(p)) return path.resolve(p); + for (const dir of (process.env.PATH || '').split(path.delimiter)) { + if (!dir) continue; + const p = path.join(dir, EXE); + if (fs.existsSync(p)) return p; + } + return null; +} diff --git a/core/s3.js b/core/s3.js new file mode 100644 index 0000000..8acaeda --- /dev/null +++ b/core/s3.js @@ -0,0 +1,221 @@ +// A small S3 client for s3d: AWS SigV4 (path-style, us-east-1, UNSIGNED-PAYLOAD +// for bodies so uploads stream straight through) plus the handful of bucket +// and object calls the console needs. + +import crypto from 'node:crypto'; +import http from 'node:http'; +import { Readable } from 'node:stream'; + +const REGION = 'us-east-1'; +const SERVICE = 's3'; + +const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); +const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest(); + +// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths. +function uriEncode(str, keepSlash) { + return encodeURIComponent(str) + .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()) + .replace(keepSlash ? /%2F/g : /$^/, '/'); +} + +function canonicalQuery(query) { + return Object.keys(query).sort() + .map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`) + .join('&'); +} + +function amzDate(d = new Date()) { + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +function signingKey(secret, date) { + return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request'); +} + +export function objectPath(bucket, key) { + return '/' + uriEncode(bucket) + (key != null ? '/' + uriEncode(key, true) : ''); +} + +export class S3Client { + // endpoint: 'http://127.0.0.1:8000' + constructor({ endpoint, accessKeyId, secretKey }) { + this.endpoint = new URL(endpoint); + this.accessKeyId = accessKeyId; + this.secretKey = secretKey; + } + + sign(method, path, query, headers, { now = amzDate(), payloadHash = 'UNSIGNED-PAYLOAD' } = {}) { + const date = now.slice(0, 8); + const h = { ...headers, host: this.endpoint.host, 'x-amz-date': now, 'x-amz-content-sha256': payloadHash }; + const names = Object.keys(h).map((k) => k.toLowerCase()).sort(); + const lower = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), String(v).trim()])); + const signed = names.join(';'); + const canonical = [method, path, canonicalQuery(query), names.map((n) => `${n}:${lower[n]}\n`).join(''), signed, payloadHash].join('\n'); + const scope = `${date}/${REGION}/${SERVICE}/aws4_request`; + const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n'); + const sig = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex'); + lower.authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${sig}`; + delete lower.host; + return lower; + } + + // Presigned GET for share links. publicEndpoint lets the link name the + // address readers will use, which may differ from the loopback the GUI uses. + presign(bucket, key, expiresSec = 3600, publicEndpoint, now = amzDate()) { + const base = publicEndpoint ? new URL(publicEndpoint) : this.endpoint; + const date = now.slice(0, 8); + const scope = `${date}/${REGION}/${SERVICE}/aws4_request`; + const path = objectPath(bucket, key); + const query = { + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${this.accessKeyId}/${scope}`, + 'X-Amz-Date': now, + 'X-Amz-Expires': String(Math.min(Math.max(1, expiresSec | 0), 604800)), + 'X-Amz-SignedHeaders': 'host', + }; + const canonical = ['GET', path, canonicalQuery(query), `host:${base.host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n'); + const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n'); + query['X-Amz-Signature'] = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex'); + return `${base.origin}${path}?${canonicalQuery(query)}`; + } + + // Low-level request. body: Buffer | string | Readable | undefined. + // Resolves the raw http.IncomingMessage so callers can stream it. + request(method, path, { query = {}, headers = {}, body } = {}) { + const signedHeaders = this.sign(method, path, query, headers); + const qs = canonicalQuery(query); + return new Promise((resolve, reject) => { + const req = http.request({ + protocol: this.endpoint.protocol, + hostname: this.endpoint.hostname, + port: this.endpoint.port, + method, + path: path + (qs ? '?' + qs : ''), + headers: signedHeaders, + }, resolve); + req.on('error', reject); + if (body instanceof Readable) body.pipe(req); + else req.end(body); + }); + } + + async call(method, path, opts) { + const res = await this.request(method, path, opts); + const text = await readBody(res); + if (res.statusCode >= 300) throw s3Error(res.statusCode, text); + return { status: res.statusCode, headers: res.headers, text }; + } + + async listBuckets() { + const { text } = await this.call('GET', '/'); + return blocks(text, 'Bucket').map((b) => ({ name: tag(b, 'Name'), created: tag(b, 'CreationDate') })); + } + + createBucket(bucket) { return this.call('PUT', objectPath(bucket)); } + deleteBucket(bucket) { return this.call('DELETE', objectPath(bucket)); } + + async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) { + const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) }; + if (token) query['continuation-token'] = token; + const { text } = await this.call('GET', objectPath(bucket), { query }); + return { + folders: blocks(text, 'CommonPrefixes').map((b) => tag(b, 'Prefix')), + objects: blocks(text, 'Contents').map((b) => ({ + key: tag(b, 'Key'), size: Number(tag(b, 'Size')), modified: tag(b, 'LastModified'), etag: tag(b, 'ETag'), + })), + truncated: tag(text, 'IsTruncated') === 'true', + nextToken: tag(text, 'NextContinuationToken'), + }; + } + + async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); } + + // Deletes every key under prefix. Returns the number deleted. + async deletePrefix(bucket, prefix) { + let n = 0; + let token; + do { + const page = await this.listObjects(bucket, { prefix, delimiter: '', token }); + for (const o of page.objects) { await this.deleteObject(bucket, o.key); n++; } + token = page.truncated ? page.nextToken : null; + } while (token); + return n; + } + + putObject(bucket, key, body, { contentType, contentLength } = {}) { + const headers = {}; + if (contentType) headers['content-type'] = contentType; + if (contentLength != null) headers['content-length'] = String(contentLength); + return this.call('PUT', objectPath(bucket, key), { headers, body }); + } + + getObject(bucket, key, { range } = {}) { + return this.request('GET', objectPath(bucket, key), { headers: range ? { range } : {} }); + } + + async getPolicy(bucket) { + const res = await this.request('GET', objectPath(bucket), { query: { policy: '' } }); + const text = await readBody(res); + if (res.statusCode === 404) return null; + if (res.statusCode >= 300) throw s3Error(res.statusCode, text); + return JSON.parse(text); + } + + putPolicy(bucket, policy) { + const body = JSON.stringify(policy); + return this.call('PUT', objectPath(bucket), { query: { policy: '' }, body, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }); + } + + deletePolicy(bucket) { return this.call('DELETE', objectPath(bucket), { query: { policy: '' } }); } +} + +// The only policy shape s3d accepts: anonymous read of objects and listing. +export function publicReadPolicy(bucket, { list = false } = {}) { + const statements = [{ + Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucket}/*`], + }]; + if (list) statements.push({ Effect: 'Allow', Principal: '*', Action: ['s3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`] }); + return { Version: '2012-10-17', Statement: statements }; +} + +export function readBody(res) { + return new Promise((resolve, reject) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + res.on('error', reject); + }); +} + +export class S3Error extends Error { + constructor(status, code, message) { super(message || code); this.status = status; this.code = code; } +} + +function s3Error(status, text) { + return new S3Error(status, tag(text, 'Code') || `HTTP ${status}`, tag(text, 'Message') || text.slice(0, 200) || `HTTP ${status}`); +} + +// S3 responses are flat enough that tag extraction is all we need. +function blocks(xml, name) { + const re = new RegExp(`<${name}>([\\s\\S]*?)`, 'g'); + const out = []; + let m; + while ((m = re.exec(xml))) out.push(m[1]); + return out; +} + +function tag(xml, name) { + const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)`)); + return m ? unescapeXml(m[1]) : null; +} + +function unescapeXml(s) { + return s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (_, e) => { + switch (e.toLowerCase()) { + case 'lt': return '<'; case 'gt': return '>'; case 'amp': return '&'; + case 'quot': return '"'; case 'apos': return "'"; + default: return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)); + } + }); +} diff --git a/core/server.js b/core/server.js new file mode 100644 index 0000000..261ec7b --- /dev/null +++ b/core/server.js @@ -0,0 +1,474 @@ +// The Pithos control server: one JSON API + the static UI. Every host (web +// console, desktop window, Theseus tab) runs exactly this and points a view +// at it, so features are written once. +// +// Auth: a session secret. Local hosts pass it in the first URL (?t=...); +// the web console can instead accept a password. Either way it is exchanged +// for an HttpOnly SameSite=Strict cookie, and mutating requests also need the +// x-pithos header so a cross-site form can never drive the API. + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +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 { LoginSession } from './login.js'; +import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js'; +import * as admin from './admin.js'; +import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const UI_DIR = path.join(HERE, '..', 'ui'); +const MIME = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', + '.woff2': 'font/woff2', +}; + +class HttpError extends Error { + constructor(status, message) { super(message); this.status = status; } +} + +export async function createPithos(opts = {}) { + const { + host = '127.0.0.1', + port = 0, + configFile: configOpt, + binary: binaryOpt, + binDir, // where installS3d puts the pinned release + password, // web console: optional login password + allowedHosts = [], // extra Host header values (reverse proxy names) + hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only + openExternal, // (url) => void, for hosts that can open the system browser + } = opts; + + const configFile = resolveConfigPath(configOpt); + const daemon = new Daemon({ configFile, binary: resolveBinary(binaryOpt, binDir) }); + const cli = makeCli(daemon); + const login = new LoginSession(daemon, { registration: () => cli.registration() }); + const secret = crypto.randomBytes(24).toString('base64url'); + const sessions = new Set(); + const subscribers = new Set(); + let versionCache = null; + + const broadcast = (event, data) => { + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of subscribers) res.write(msg); + }; + daemon.on('status', (s) => { + // A registration done outside Pithos (s3d login by hand) shows up on the next start. + if (s.state === 'starting' && !regCache?.registered) regCache = null; + broadcast('status', s); + }); + daemon.on('log', (l) => broadcast('log', l)); + login.on('state', (s) => { + if (s.state === 'done' || s.state === 'already') regCache = { registered: true, indexerUrl: s.indexerUrl || regCache?.indexerUrl }; + broadcast('login', s); + }); + + // Registration costs an s3d process run, so it is cached and only + // recomputed when something that could change it happens. + let regCache = null; + async function registration() { + if (!regCache && daemon.binary) regCache = await cli.registration().catch(() => null); + return regCache; + } + + function cfg() { return readConfig(configFile); } + + async function version() { + if (!daemon.binary) return null; + if (versionCache?.binary === daemon.binary) return versionCache.info; + try { + const info = await cli.version(); + versionCache = { binary: daemon.binary, info }; + return info; + } catch (e) { + return { version: null, error: e.message }; + } + } + + // An s3d we did not start (a service, or one run by hand) that answers on + // the configured admin address. + async function probeExternal(c) { + if (daemon.child || !c.adminPassword) return false; + try { await admin.uploadStats(c); return true; } catch { return false; } + } + + async function s3For(user) { + const keys = await cli.listKeys(user); + if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`); + const c = cfg(); + return new S3Client({ endpoint: `http://${c.apiAddress}`, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey }); + } + + // ---- routing --------------------------------------------------------- + + const routes = []; + const route = (method, pattern, handler) => { + const keys = []; + const re = new RegExp('^' + pattern.replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '$'); + routes.push({ method, re, keys, handler }); + }; + + route('GET', '/api/status', async () => { + const c = cfg(); + const v = await version(); + return { + host: hostName, + daemon: daemon.status(), + external: await probeExternal(c), + s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) }, + config: { file: c.file, exists: c.exists, apiAddress: c.apiAddress, adminAddress: c.adminAddress, directory: c.directory, https: c.data.apiHTTPSAddress || null }, + login: login.snapshot(), + registration: await registration(), + }; + }); + + route('POST', '/api/daemon/start', async () => { + ensureConfig(configFile); + return daemon.start(); + }); + route('POST', '/api/daemon/stop', () => daemon.stop()); + route('POST', '/api/daemon/restart', async () => { + await daemon.stop(); + ensureConfig(configFile); + return daemon.start(); + }); + route('GET', '/api/logs', (req, url) => daemon.logsSince(Number(url.searchParams.get('since') || 0))); + + route('POST', '/api/binary/install', async () => { + if (daemon.child) throw new HttpError(409, 'stop s3d before replacing its binary'); + if (!binDir) throw new HttpError(400, 'this host has no writable bin folder'); + const target = await installS3d(binDir, (p) => broadcast('install', p)); + daemon.binary = target; + versionCache = null; + regCache = null; + return { binary: target, version: await version() }; + }); + + route('GET', '/api/config', () => { + const c = cfg(); + const values = {}; + for (const k of EDITABLE) values[k] = getIn(c.data, k.split('.')) ?? null; + return { file: c.file, exists: c.exists, values }; + }); + route('PUT', '/api/config', async (req) => { + const patch = await jsonBody(req); + const c = writeConfig(configFile, patch); + if ('directory' in patch) regCache = null; + return { file: c.file, restartNeeded: !!daemon.child }; + }); + + // Login (indexer registration) + route('GET', '/api/login', () => login.snapshot()); + route('POST', '/api/login', async (req) => { + const { indexerUrl, phrase } = await jsonBody(req); + if (phrase && phrase.trim().split(/\s+/).length !== 12) throw new HttpError(400, 'a recovery phrase is 12 words'); + if (indexerUrl && !/^https?:\/\/[^\s]+$/.test(indexerUrl)) throw new HttpError(400, 'indexer URL must be http(s)'); + ensureConfig(configFile); + return login.start({ indexerUrl: indexerUrl?.trim(), phrase: phrase?.trim().toLowerCase().split(/\s+/).join(' ') }); + }); + route('POST', '/api/login/confirm', () => { login.confirmGenerated(); return login.snapshot(); }); + route('POST', '/api/login/cancel', () => { login.cancel(); return login.snapshot(); }); + route('POST', '/api/open-external', async (req) => { + const { url } = await jsonBody(req); + if (!openExternal) throw new HttpError(400, 'not supported by this host'); + if (!/^https:\/\//.test(url)) throw new HttpError(400, 'only https links'); + openExternal(url); + return { ok: true }; + }); + + // Users & keys + route('GET', '/api/users', async () => { + const [users, keys] = await Promise.all([cli.listUsers(), cli.listKeys()]); + return users.map((name) => ({ name, keys: keys.filter((k) => k.user === name).map((k) => k.accessKeyId) })); + }); + route('POST', '/api/users', async (req) => { + const { name, withKey = true } = await jsonBody(req); + await cli.createUser(name); + const key = withKey ? await cli.createKey(name) : null; + return { name, key }; + }); + route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; }); + 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); + return cli.createKey(user, { accessKey, secretKey }); + }); + route('DELETE', '/api/keys/:id', async (req, url, p) => { await cli.deleteKey(p.id); return { ok: true }; }); + + // Upload pipeline + route('GET', '/api/stats', () => admin.uploadStats(cfg())); + route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; }); + + // Buckets & objects, acting as one s3d user. + route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets()); + route('POST', '/api/s3/:user/buckets', async (req, url, p) => { + const { name } = await jsonBody(req); + if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(name || '')) throw new HttpError(400, 'bucket names are 3-63 chars: lowercase letters, digits, dots, hyphens'); + await (await s3For(p.user)).createBucket(name); + return { name }; + }); + route('DELETE', '/api/s3/:user/buckets/:bucket', async (req, url, p) => { + await (await s3For(p.user)).deleteBucket(p.bucket); + return { ok: true }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/objects', async (req, url, p) => { + const q = url.searchParams; + return (await s3For(p.user)).listObjects(p.bucket, { prefix: q.get('prefix') || '', token: q.get('token') || undefined }); + }); + route('PUT', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => { + const key = requireKey(url); + const len = req.headers['content-length']; + if (len == null) throw new HttpError(411, 'Content-Length required'); + if (Number(len) > 5 * 1024 ** 3) throw new HttpError(413, 'single uploads are limited to 5 GiB'); + await (await s3For(p.user)).putObject(p.bucket, key, req, { contentType: req.headers['content-type'], contentLength: len }); + return { key }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/object', async (req, url, p, res) => { + const key = requireKey(url); + const up = await (await s3For(p.user)).getObject(p.bucket, key, { range: req.headers.range }); + if (up.statusCode >= 300 && up.statusCode !== 304) { + throw new HttpError(up.statusCode, (await readBody(up)).match(/(.*?)<\/Message>/)?.[1] || `HTTP ${up.statusCode}`); + } + const headers = {}; + for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']) if (up.headers[h]) headers[h] = up.headers[h]; + const name = key.split('/').pop() || 'download'; + headers['content-disposition'] = `${url.searchParams.get('inline') ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`; + headers['x-content-type-options'] = 'nosniff'; + // Objects are user content: never let them run script on this origin. + headers['content-security-policy'] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'"; + res.writeHead(up.statusCode, headers); + up.pipe(res); + return STREAMED; + }); + route('DELETE', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => { + await (await s3For(p.user)).deleteObject(p.bucket, requireKey(url)); + return { ok: true }; + }); + route('DELETE', '/api/s3/:user/buckets/:bucket/prefix', async (req, url, p) => { + const prefix = url.searchParams.get('prefix'); + if (!prefix) throw new HttpError(400, 'prefix required'); + return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => { + const q = url.searchParams; + const client = await s3For(p.user); + return { url: client.presign(p.bucket, requireKey(url), Number(q.get('expires') || 3600), q.get('endpoint') || undefined) }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => { + const policy = await (await s3For(p.user)).getPolicy(p.bucket); + return { policy, public: describePolicy(policy) }; + }); + route('PUT', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => { + const { mode } = await jsonBody(req); // 'private' | 'read' | 'read-list' + const client = await s3For(p.user); + if (mode === 'private') { await client.deletePolicy(p.bucket).catch((e) => { if (e.status !== 404) throw e; }); } + else if (mode === 'read' || mode === 'read-list') await client.putPolicy(p.bucket, publicReadPolicy(p.bucket, { list: mode === 'read-list' })); + else throw new HttpError(400, 'mode must be private, read or read-list'); + return { ok: true }; + }); + + // ---- server ------------------------------------------------------------ + + const STREAMED = Symbol('streamed'); + let boundPort = port; + + function hostAllowed(h) { + if (!h) return false; + const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts]; + return ok.includes(h.toLowerCase()); + } + + function cookieSession(req) { + const m = /(?:^|;\s*)pithos=([^;]+)/.exec(req.headers.cookie || ''); + return m && sessions.has(m[1]) ? m[1] : null; + } + + function issueSession(res) { + const id = crypto.randomBytes(24).toString('base64url'); + sessions.add(id); + res.setHeader('set-cookie', `pithos=${id}; HttpOnly; SameSite=Strict; Path=/`); + return id; + } + + const server = http.createServer(async (req, res) => { + // DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still + // sends its own Host header. + if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); } + const url = new URL(req.url, `http://${req.headers.host}`); + res.setHeader('x-frame-options', 'DENY'); + res.setHeader('referrer-policy', 'no-referrer'); + + try { + // Session bootstrap: ?t= on any page swaps the secret for a cookie. + const t = url.searchParams.get('t'); + if (t && safeEqual(t, secret) && req.method === 'GET' && !url.pathname.startsWith('/api/')) { + issueSession(res); + res.writeHead(302, { location: url.pathname }); + return res.end(); + } + + if (url.pathname === '/api/session') { + if (req.method === 'GET') return sendJson(res, 200, { authenticated: !!cookieSession(req), passwordLogin: !!password, host: hostName }); + if (req.method === 'POST') { + requireCsrf(req); + const body = await jsonBody(req); + if (!password || !safeEqual(String(body.password || ''), password)) { + await new Promise((r) => setTimeout(r, 750)); + throw new HttpError(401, 'wrong password'); + } + issueSession(res); + return sendJson(res, 200, { authenticated: true }); + } + if (req.method === 'DELETE') { + const s = cookieSession(req); + if (s) sessions.delete(s); + res.setHeader('set-cookie', 'pithos=; Max-Age=0; Path=/'); + return sendJson(res, 200, { authenticated: false }); + } + } + + if (url.pathname.startsWith('/api/')) { + if (!cookieSession(req)) throw new HttpError(401, 'not signed in'); + if (req.method !== 'GET') requireCsrf(req); + + if (url.pathname === '/api/events') { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' }); + res.write(`event: status\ndata: ${JSON.stringify(daemon.status())}\n\n`); + res.write(`event: login\ndata: ${JSON.stringify(login.snapshot())}\n\n`); + subscribers.add(res); + const ping = setInterval(() => res.write(': ping\n\n'), 25_000); + req.on('close', () => { clearInterval(ping); subscribers.delete(res); }); + return; + } + + for (const r of routes) { + if (r.method !== req.method) continue; + const m = r.re.exec(url.pathname); + if (!m) continue; + const params = Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])); + const out = await r.handler(req, url, params, res); + if (out === STREAMED) return; + return sendJson(res, 200, out ?? { ok: true }); + } + throw new HttpError(404, 'no such endpoint'); + } + + return serveStatic(req, res, url); + } catch (err) { + const status = err instanceof HttpError ? err.status + : err instanceof S3Error ? (err.status >= 400 && err.status < 600 ? err.status : 502) + : err instanceof CliError ? 400 + : 500; + if (status === 500) console.error('[pithos]', err); + if (res.headersSent) return res.destroy(); + sendJson(res, status, { error: err.message, code: err.code }); + } + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, resolve); + }); + boundPort = server.address().port; + const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; + const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`; + + return { + server, + daemon, + url: baseUrl, + // Opening this URL signs the view in. + authUrl: `${baseUrl}?t=${secret}`, + async close() { + login.cancel(); + for (const s of subscribers) s.end(); + await daemon.stop(); + await new Promise((r) => server.close(r)); + }, + }; +} + +// ---- helpers ------------------------------------------------------------- + +function sendJson(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(data); +} + +function requireCsrf(req) { + if (req.headers['x-pithos'] !== '1') throw new HttpError(403, 'missing x-pithos header'); +} + +function requireKey(url) { + const key = url.searchParams.get('key'); + if (!key) throw new HttpError(400, 'key required'); + return key; +} + +async function jsonBody(req) { + const chunks = []; + let size = 0; + for await (const c of req) { + size += c.length; + if (size > 1 << 20) throw new HttpError(413, 'body too large'); + chunks.push(c); + } + if (!chunks.length) return {}; + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'invalid JSON'); } +} + +function safeEqual(a, b) { + const x = Buffer.from(String(a)); + const y = Buffer.from(String(b)); + return x.length === y.length && crypto.timingSafeEqual(x, y); +} + +function getIn(obj, keys) { + return keys.reduce((o, k) => (o == null ? undefined : o[k]), obj); +} + +function cmpVersion(a, b) { + const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); + const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + return 0; +} + +function describePolicy(policy) { + if (!policy) return 'private'; + const actions = new Set(); + for (const s of [].concat(policy.Statement || [])) { + if (s.Effect !== 'Allow') continue; + if (s.Principal !== '*' && s.Principal?.AWS !== '*') continue; + for (const a of [].concat(s.Action || [])) actions.add(a); + } + if (actions.has('s3:ListBucket')) return 'read-list'; + if (actions.has('s3:GetObject')) return 'read'; + return 'custom'; +} + +function serveStatic(req, res, url) { + if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); } + let rel = decodeURIComponent(url.pathname); + if (rel === '/' || !path.extname(rel)) rel = '/index.html'; + const file = path.join(UI_DIR, path.normalize(rel)); + if (!file.startsWith(UI_DIR + path.sep)) { res.writeHead(403); return res.end(); } + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { + 'content-type': MIME[path.extname(file)] || 'application/octet-stream', + 'cache-control': 'no-cache', + 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", + }); + res.end(req.method === 'HEAD' ? undefined : data); + }); +} diff --git a/desktop/main.cjs b/desktop/main.cjs new file mode 100644 index 0000000..9e43657 --- /dev/null +++ b/desktop/main.cjs @@ -0,0 +1,77 @@ +// Pithos desktop: an Electron window over the same control server the web +// console runs. The server binds a random loopback port and the window signs +// in with the one-time secret, so nothing else on the machine can drive it. + +const { app, BrowserWindow, shell, Menu, dialog } = require('electron'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +let pithos; +let win; + +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on('second-instance', () => { + if (win) { if (win.isMinimized()) win.restore(); win.focus(); } + }); + app.whenReady().then(start).catch((err) => { + dialog.showErrorBox('Pithos failed to start', String(err?.stack || err)); + app.quit(); + }); +} + +async function start() { + const { createPithos } = await import(pathToFileURL(path.join(__dirname, '..', 'core', 'server.js')).href); + pithos = await createPithos({ + host: '127.0.0.1', + port: 0, + hostName: 'desktop', + configFile: process.env.S3D_CONFIG_FILE || undefined, + // The pinned s3d release installs here, outside the (read-only) app bundle. + binDir: path.join(app.getPath('userData'), 'bin'), + openExternal: (url) => shell.openExternal(url), + }); + + Menu.setApplicationMenu(process.platform === 'darwin' ? Menu.buildFromTemplate([ + { role: 'appMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' }, + ]) : null); + + win = new BrowserWindow({ + width: 1180, + height: 800, + minWidth: 380, + minHeight: 500, + title: 'Pithos', + backgroundColor: '#0f1214', + autoHideMenuBar: true, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, + }); + + const origin = new URL(pithos.url).origin; + // Keep the window on the console; anything else goes to the system browser. + win.webContents.on('will-navigate', (e, url) => { + if (new URL(url).origin !== origin) { + e.preventDefault(); + if (url.startsWith('https://')) shell.openExternal(url); + } + }); + win.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('https://')) shell.openExternal(url); + return { action: 'deny' }; + }); + + win.loadURL(pithos.authUrl); + win.on('closed', () => { win = null; }); +} + +let quitting = false; +app.on('before-quit', (e) => { + if (quitting || !pithos) return; + // Stop s3d cleanly so its upload buffer and database are flushed to disk. + e.preventDefault(); + quitting = true; + pithos.close().finally(() => app.quit()); +}); + +app.on('window-all-closed', () => app.quit()); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cba6ae3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,295 @@ +{ + "name": "pithos", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pithos", + "version": "0.1.0", + "license": "MPL-2.0", + "dependencies": { + "yaml": "^2.9.1" + }, + "bin": { + "pithos-web": "bin/pithos-web.js" + }, + "devDependencies": { + "electron": "^44.5.1" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@lydell/node-pty": "1.2.0-beta.15" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@lydell/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A==", + "license": "MIT", + "optional": true, + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.15", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.15", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.15", + "@lydell/node-pty-linux-x64": "1.2.0-beta.15", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.15", + "@lydell/node-pty-win32-x64": "1.2.0-beta.15" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron": { + "version": "44.5.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.5.1.tgz", + "integrity": "sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec7418e --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "pithos", + "version": "0.1.0", + "description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.", + "license": "MPL-2.0", + "type": "module", + "main": "core/server.js", + "bin": { "pithos-web": "bin/pithos-web.js" }, + "scripts": { + "web": "node bin/pithos-web.js", + "desktop": "electron desktop/main.cjs", + "test": "node --test test/*.test.js" + }, + "engines": { "node": ">=20" }, + "dependencies": { + "yaml": "^2.9.1" + }, + "optionalDependencies": { + "@lydell/node-pty": "1.2.0-beta.15" + }, + "devDependencies": { + "electron": "^44.5.1" + } +} diff --git a/scripts/build-theseus-addon.mjs b/scripts/build-theseus-addon.mjs new file mode 100644 index 0000000..d20de23 --- /dev/null +++ b/scripts/build-theseus-addon.mjs @@ -0,0 +1,49 @@ +// Assembles the Theseus add-on folder: manifest + entry from theseus/, the +// shared core/ and ui/, and the one pure-JS dependency (yaml, vendored). The pty module +// is left out on purpose (12 MB of native code per platform); without it the +// login flow opens s3d in its own console window instead. +// +// node scripts/build-theseus-addon.mjs [outDir] default: dist/theseus-addon/pithos + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const out = path.resolve(process.argv[2] || path.join(ROOT, 'dist', 'theseus-addon', 'pithos')); + +fs.rmSync(out, { recursive: true, force: true }); +fs.mkdirSync(out, { recursive: true }); + +const copy = (from, to) => fs.cpSync(path.join(ROOT, from), path.join(out, to), { recursive: true }); +copy('theseus/addon.json', 'addon.json'); +copy('theseus/index.js', 'index.js'); +copy('core', 'core'); +copy('ui', 'ui'); +// core/ is ESM; the add-on root stays CommonJS because the host require()s +// index.js. Both are pinned so a parent package.json cannot flip either. +fs.writeFileSync(path.join(out, 'package.json'), JSON.stringify({ type: 'commonjs', private: true }, null, 2) + '\n'); +fs.writeFileSync(path.join(out, 'core', 'package.json'), JSON.stringify({ type: 'module' }, null, 2) + '\n'); + +// Vendored as vendor/yaml/lib, not node_modules/yaml/dist: Theseus git-ignores +// every node_modules/ and dist/ folder, so a clean-worktree release build +// would ship the add-on without it. yaml's dist only requires its own files +// and Node built-ins, so the folder moves as-is. +const yamlPkg = path.join(ROOT, 'node_modules', 'yaml'); +fs.cpSync(path.join(yamlPkg, 'dist'), path.join(out, 'vendor', 'yaml', 'lib'), { recursive: true, filter: (f) => !f.endsWith('.d.ts') }); +fs.copyFileSync(path.join(yamlPkg, 'LICENSE'), path.join(out, 'vendor', 'yaml', 'LICENSE')); +const yamlVersion = JSON.parse(fs.readFileSync(path.join(yamlPkg, 'package.json'), 'utf8')).version; +fs.writeFileSync(path.join(out, 'vendor', 'yaml', 'package.json'), JSON.stringify({ name: 'yaml', version: yamlVersion, type: 'commonjs', private: true }, null, 2) + '\n'); +const configJs = path.join(out, 'core', 'config.js'); +const src = fs.readFileSync(configJs, 'utf8'); +const rewritten = src.replace("from 'yaml';", "from '../vendor/yaml/lib/index.js';"); +if (rewritten === src) throw new Error('core/config.js no longer imports yaml the expected way'); +fs.writeFileSync(configJs, rewritten); +fs.copyFileSync(path.join(ROOT, 'LICENSE'), path.join(out, 'LICENSE')); + +const manifest = JSON.parse(fs.readFileSync(path.join(out, 'addon.json'), 'utf8')); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +if (manifest.version !== pkg.version) { + console.warn(`warning: addon.json version ${manifest.version} != package.json ${pkg.version}`); +} +console.log(`built ${manifest.id} ${manifest.version} -> ${out}`); diff --git a/test/sigv4.test.js b/test/sigv4.test.js new file mode 100644 index 0000000..cc4f88b --- /dev/null +++ b/test/sigv4.test.js @@ -0,0 +1,79 @@ +// Cross-checks the SigV4 signer against the real AWS CLI: a capture server +// records what `aws` sends, and we re-sign the same request ourselves. Skips +// when the aws CLI is not installed. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { execFileSync, execFile } from 'node:child_process'; +import { S3Client, objectPath } from '../core/s3.js'; + +const AK = 'AKIDEXAMPLE12345678'; +const SK = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY'; + +let hasAws = true; +try { execFileSync('aws', ['--version'], { stdio: 'ignore' }); } catch { hasAws = false; } + +function capture(fn) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/xml' }); + res.end('false'); + server.close(); + resolve({ method: req.method, url: req.url, headers: req.headers }); + }); + }); + server.listen(0, '127.0.0.1', () => fn(server.address().port).catch(reject)); + }); +} + +function runAws(args) { + return new Promise((resolve) => execFile('aws', args, { + env: { ...process.env, AWS_ACCESS_KEY_ID: AK, AWS_SECRET_ACCESS_KEY: SK, AWS_REGION: 'us-east-1', AWS_CONFIG_FILE: 'nul', AWS_SHARED_CREDENTIALS_FILE: 'nul' }, + }, () => resolve())); +} + +function resign(port, req, bucket, key) { + const client = new S3Client({ endpoint: `http://127.0.0.1:${port}`, accessKeyId: AK, secretKey: SK }); + const auth = req.headers.authorization; + const signedNames = auth.match(/SignedHeaders=([^,]+)/)[1].split(';'); + const headers = {}; + for (const n of signedNames) if (!['host', 'x-amz-date', 'x-amz-content-sha256'].includes(n)) headers[n] = req.headers[n]; + const u = new URL(req.url, 'http://x'); + const query = {}; + for (const [k, v] of u.searchParams) query[k] = v; + const ours = client.sign(req.method, objectPath(bucket, key), query, headers, { + now: req.headers['x-amz-date'], payloadHash: req.headers['x-amz-content-sha256'], + }); + return { theirs: auth.match(/Signature=(\w+)/)[1], ours: ours.authorization.match(/Signature=(\w+)/)[1] }; +} + +test('list with awkward prefix matches aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => { + let port; + const req = await capture((p) => { port = p; return runAws(['s3api', 'list-objects-v2', '--bucket', 'my-bucket', '--prefix', "a b/ü+x=(1)*'~", '--endpoint-url', `http://127.0.0.1:${p}`]); }); + const { theirs, ours } = resign(port, req, 'my-bucket'); + assert.equal(ours, theirs); +}); + +test('object key path encoding matches aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => { + let port; + const key = "dir/a b+c(1)!ü*'~.txt"; + const req = await capture((p) => { port = p; return runAws(['s3api', 'delete-object', '--bucket', 'my-bucket', '--key', key, '--endpoint-url', `http://127.0.0.1:${p}`]); }); + const { theirs, ours } = resign(port, req, 'my-bucket', key); + assert.equal(ours, theirs); +}); + +test('presigned URL matches aws cli', { skip: !hasAws && 'aws cli not installed' }, () => { + const key = 'dir/report 2026 (final).pdf'; + const theirs = execFileSync('aws', ['s3', 'presign', `s3://my-bucket/${key}`, '--expires-in', '3600', '--endpoint-url', 'http://127.0.0.1:18000'], { + env: { ...process.env, AWS_ACCESS_KEY_ID: AK, AWS_SECRET_ACCESS_KEY: SK, AWS_REGION: 'us-east-1', AWS_CONFIG_FILE: 'nul', AWS_SHARED_CREDENTIALS_FILE: 'nul' }, + }).toString().trim(); + const t = new URL(theirs); + const client = new S3Client({ endpoint: 'http://127.0.0.1:18000', accessKeyId: AK, secretKey: SK }); + const ours = new URL(client.presign('my-bucket', key, 3600, undefined, t.searchParams.get('X-Amz-Date'))); + assert.equal(ours.pathname, t.pathname); + assert.equal(ours.searchParams.get('X-Amz-Signature'), t.searchParams.get('X-Amz-Signature')); +}); diff --git a/theseus/addon.json b/theseus/addon.json new file mode 100644 index 0000000..8c6a4c9 --- /dev/null +++ b/theseus/addon.json @@ -0,0 +1,27 @@ +{ + "id": "pithos", + "name": "Pithos", + "version": "0.1.0", + "description": "Run s3d, the Sia S3 gateway, from Theseus: 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=", + "main": "index.js", + "capabilities": [ + "toolbar-menu" + ], + "toolbar-menu": { + "title": "Pithos", + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", + "items": [ + { + "id": "open", + "label": "Open Pithos" + }, + { + "id": "stop", + "label": "Stop s3d" + } + ] + }, + "updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json" +} diff --git a/theseus/index.js b/theseus/index.js new file mode 100644 index 0000000..2d2657b --- /dev/null +++ b/theseus/index.js @@ -0,0 +1,57 @@ +// Pithos for Theseus. The add-on runs the same control server as the web +// console and desktop app, in Theseus's main process, and opens its UI in a +// normal tab at a random loopback port. The first URL carries a one-time +// secret that the server swaps for an HttpOnly cookie. +// +// Loaded with require() by the add-on host; core/ is ESM, hence import(). + +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +let pithos = null; +let starting = null; + +module.exports = { + activate(api) { + // The pinned s3d release lands in the per-add-on data folder, never in the + // add-on folder itself (that one is replaced on update). + const dataDir = path.join(api.dataDir || path.join(api.folder, "..", "..", "extensions-data"), "pithos"); + + async function ensureServer() { + if (pithos) return pithos; + if (!starting) { + starting = (async () => { + const { createPithos } = await import(pathToFileURL(path.join(__dirname, "core", "server.js")).href); + pithos = await createPithos({ + host: "127.0.0.1", + port: 0, + hostName: "theseus", + binDir: path.join(dataDir, "bin"), + openExternal: (url) => api.openTab(url), + }); + api.log(`control server on ${pithos.url}`); + return pithos; + })().finally(() => { starting = null; }); + } + return starting; + } + + api.onMessage("menu-select", async (payload) => { + const id = payload && payload.id; + if (id === "open") { + const p = await ensureServer(); + api.openTab(p.authUrl); + } else if (id === "stop") { + if (pithos) await pithos.daemon.stop(); + } + }); + + // The host has no quit hook for add-ons; without this an s3d we started + // would outlive Theseus. + process.on("exit", () => { try { pithos && pithos.daemon.child && pithos.daemon.child.kill(); } catch {} }); + }, + + async deactivate() { + if (pithos) { const p = pithos; pithos = null; await p.close(); } + }, +}; diff --git a/ui/app.css b/ui/app.css new file mode 100644 index 0000000..eb84d11 --- /dev/null +++ b/ui/app.css @@ -0,0 +1,211 @@ +:root { + --bg: #f6f7f8; + --surface: #ffffff; + --surface-2: #f0f2f4; + --border: #dfe3e7; + --text: #15191d; + --muted: #5d6873; + --accent: #1e9e72; + --accent-ink: #ffffff; + --accent-soft: #e2f5ed; + --danger: #c93a3a; + --danger-soft: #fbe9e9; + --warn: #b7791f; + --warn-soft: #fdf3e1; + --mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace; + --radius: 10px; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1214; + --surface: #171b1f; + --surface-2: #1e2328; + --border: #2a3036; + --text: #e6eaee; + --muted: #8d98a3; + --accent: #3ddc97; + --accent-ink: #06231a; + --accent-soft: #123327; + --danger: #ef6b6b; + --danger-soft: #3a1c1c; + --warn: #e0a84a; + --warn-soft: #3a2c14; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); color: var(--text); + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +[hidden] { display: none !important; } +a { color: var(--accent); } +h1 { font-size: 22px; margin: 0 0 4px; font-weight: 650; } +h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; } +h3 { font-size: 14px; margin: 0 0 8px; font-weight: 600; } +code, .mono { font-family: var(--mono); font-size: 12.5px; } +.muted { color: var(--muted); } +.error { color: var(--danger); } +.small { font-size: 12.5px; } + +/* layout */ +.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100%; } +.sidebar { + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; padding: 16px 12px; gap: 16px; + position: sticky; top: 0; height: 100vh; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; padding: 0 8px; } +nav { display: flex; flex-direction: column; gap: 2px; } +nav a { + color: var(--text); text-decoration: none; padding: 8px 10px; border-radius: 8px; +} +nav a:hover { background: var(--surface-2); } +nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.daemon-pill { + margin-top: auto; display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: 8px; background: var(--surface-2); font-size: 13px; +} +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; } +.dot.running { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.dot.starting, .dot.stopping { background: var(--warn); } +.dot.crashed { background: var(--danger); } + +main { padding: 28px 32px 48px; max-width: 1100px; width: 100%; } +.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; } +.page-head p { margin: 0; } + +.grid { display: grid; gap: 16px; } +.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.card { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 18px 20px; +} +.card + .card { margin-top: 16px; } +.grid > .card + .card { margin-top: 0; } + +.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.row.spread { justify-content: space-between; } +.stack { display: flex; flex-direction: column; gap: 10px; } + +dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; } +dl.kv dt { color: var(--muted); } +dl.kv dd { margin: 0; overflow-wrap: anywhere; } + +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } +.stat { background: var(--surface-2); border-radius: 8px; padding: 12px 14px; } +.stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; } +.stat .l { color: var(--muted); font-size: 12.5px; } +.stat.bad .v { color: var(--danger); } + +/* controls */ +.btn { + appearance: none; border: 1px solid var(--border); background: var(--surface); + color: var(--text); padding: 7px 14px; border-radius: 8px; font: inherit; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; +} +.btn:hover:not(:disabled) { background: var(--surface-2); } +.btn:disabled { opacity: .5; cursor: default; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; } +.btn.primary:hover:not(:disabled) { filter: brightness(1.06); background: var(--accent); } +.btn.danger { color: var(--danger); } +.btn.danger:hover:not(:disabled) { background: var(--danger-soft); } +.btn.small { padding: 3px 9px; font-size: 12.5px; } +.btn.link { border: none; background: none; color: var(--accent); padding: 0; } + +input[type=text], input[type=password], input[type=number], input[type=url], select, textarea { + font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; padding: 7px 10px; width: 100%; +} +textarea { resize: vertical; min-height: 70px; } +input:focus, select:focus, textarea:focus, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +label.field { display: flex; flex-direction: column; gap: 4px; } +label.field > span { font-weight: 550; font-size: 13px; } +label.field > small { color: var(--muted); } +label.check { display: flex; align-items: center; gap: 8px; } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } + +/* tables */ +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; } +th { color: var(--muted); font-weight: 550; font-size: 12.5px; } +tr:last-child td { border-bottom: none; } +td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +td.actions { text-align: right; white-space: nowrap; } +tr.clickable { cursor: pointer; } +tr.clickable:hover td { background: var(--surface-2); } +.empty { text-align: center; color: var(--muted); padding: 28px 10px; } + +.badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; background: var(--surface-2); color: var(--muted); } +.badge.ok { background: var(--accent-soft); color: var(--accent); } +.badge.warn { background: var(--warn-soft); color: var(--warn); } + +.banner { border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px; display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.banner.warn { background: var(--warn-soft); color: var(--text); border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); } +.banner.info { background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); } + +.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 15px; } +.crumbs button { border: none; background: none; color: var(--accent); cursor: pointer; font: inherit; padding: 2px 4px; border-radius: 4px; } +.crumbs button:hover { background: var(--surface-2); } +.crumbs .sep { color: var(--muted); } + +.dropzone { position: relative; } +.dropzone.over::after { + content: "Drop files to upload"; position: absolute; inset: 0; display: grid; place-items: center; + background: color-mix(in srgb, var(--accent-soft) 85%, transparent); border: 2px dashed var(--accent); + border-radius: var(--radius); font-weight: 600; color: var(--accent); +} + +.uploads { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.upload { display: grid; grid-template-columns: 1fr 160px 70px; gap: 10px; align-items: center; font-size: 13px; } +.bar { height: 6px; background: var(--surface-2); border-radius: 3px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; } + +.logs { + font-family: var(--mono); font-size: 12px; background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px; height: calc(100vh - 170px); overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; +} +.logs .err { color: var(--danger); } +.logs .sys { color: var(--muted); font-style: italic; } + +.secret { display: flex; gap: 6px; align-items: center; } +.secret code { background: var(--surface-2); padding: 3px 8px; border-radius: 6px; overflow-wrap: anywhere; } +pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8px; overflow: auto; margin: 0; font-family: var(--mono); font-size: 12px; } + +.phrase { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; counter-reset: w; } +.phrase span { background: var(--surface-2); padding: 6px 8px; border-radius: 6px; font-family: var(--mono); counter-increment: w; } +.phrase span::before { content: counter(w) ". "; color: var(--muted); } + +.steps { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.steps span { padding: 3px 10px; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 12.5px; } +.steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + +/* modal + toasts */ +.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 10; } +.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; } +.modal h2 { margin-bottom: 14px; } +.modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 20; } +.toast { background: var(--text); color: var(--bg); padding: 10px 14px; border-radius: 8px; max-width: 380px; box-shadow: 0 6px 20px rgb(0 0 0 / .2); } +.toast.error { background: var(--danger); color: #fff; } + +.signin { display: grid; place-items: center; min-height: 100%; padding: 16px; } +.signin-card { width: min(340px, 100%); display: flex; flex-direction: column; gap: 12px; align-items: stretch; text-align: center; } +.signin-card img { align-self: center; } + +.spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } + +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; padding: 10px 16px; } + nav { flex-direction: row; flex-wrap: wrap; } + .daemon-pill { margin-top: 0; margin-left: auto; } + main { padding: 20px 16px 40px; } + .upload { grid-template-columns: 1fr 80px 50px; } + .hide-sm { display: none; } +} diff --git a/ui/app.js b/ui/app.js new file mode 100644 index 0000000..afe925d --- /dev/null +++ b/ui/app.js @@ -0,0 +1,916 @@ +// Pithos UI. Plain DOM, no build step: every host serves these files as-is. +// All user content (object keys, user names, log lines) goes in via +// textContent through h(); nothing here assigns innerHTML. + +// ---------------------------------------------------------------- helpers + +function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'dataset') Object.assign(el.dataset, v); + else if (v === true) el.setAttribute(k, ''); + else el.setAttribute(k, v); + } + for (const c of children.flat(Infinity)) { + if (c == null || c === false) continue; + el.append(c instanceof Node ? c : String(c)); + } + return el; +} + +const $ = (sel, root = document) => root.querySelector(sel); + +// replaceChildren, but skipping the null/false that conditional children produce. +function put(el, ...children) { + el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false)); +} + +async function api(method, path, body, { raw } = {}) { + const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' }; + if (body !== undefined) { + opts.headers['content-type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + if (res.status === 401 && path !== '/api/session') { showSignin(); throw new Error('signed out'); } + if (raw) return res; + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function toast(msg, kind = '') { + const el = h('div', { class: `toast ${kind}` }, msg); + $('#toasts').append(el); + setTimeout(() => el.remove(), kind === 'error' ? 7000 : 3500); +} + +const fail = (e) => { if (e.message !== 'signed out') toast(e.message, 'error'); }; + +function fmtBytes(n) { + if (n == null || isNaN(n)) return '–'; + const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${i ? n.toFixed(n < 10 ? 2 : 1) : n} ${u[i]}`; +} +function fmtDate(s) { if (!s) return '–'; const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(); } +function fmtDuration(ms) { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; +} + +async function copy(text, what = 'Copied') { + try { await navigator.clipboard.writeText(text); toast(what); } + catch { prompt('Copy:', text); } +} + +function modal(title, body, buttons = []) { + return new Promise((resolve) => { + const close = (v) => { root.remove(); document.removeEventListener('keydown', onKey); resolve(v); }; + const onKey = (e) => { if (e.key === 'Escape') close(undefined); }; + const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', { + class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button', + onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; if (v !== false) close(v); }, + }, b.label))); + const form = h('form', { class: 'modal', onsubmit: async (e) => { + e.preventDefault(); + const b = buttons.find((x) => x.submit); + const v = b.value ? await b.value() : b.result; + if (v !== false) close(v); + } }, h('h2', {}, title), body, foot); + const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form); + $('#modal-root').append(root); + document.addEventListener('keydown', onKey); + setTimeout(() => (form.querySelector('input, textarea, select') || form.querySelector('.btn.primary'))?.focus(), 0); + }); +} + +function confirmModal(title, text, label = 'Delete', kind = 'danger') { + return modal(title, h('p', {}, text), [ + { label: 'Cancel', result: false }, + { label, kind: `primary ${kind === 'danger' ? '' : ''}`, submit: true, result: true }, + ]).then((v) => v === true); +} + +function secretRow(label, value) { + return h('div', { class: 'stack' }, + h('span', { class: 'small muted' }, label), + h('div', { class: 'secret' }, h('code', {}, value), h('button', { class: 'btn small', type: 'button', onclick: () => copy(value) }, 'Copy'))); +} + +// ---------------------------------------------------------------- state + +const state = { + status: null, + login: { state: 'idle' }, + logs: [], + logSeq: 0, + selectedUser: localStorage.getItem('pithos.user') || '', +}; +const listeners = new Set(); +const onState = (fn) => { listeners.add(fn); return () => listeners.delete(fn); }; +const emit = () => listeners.forEach((fn) => fn()); + +async function refreshStatus() { + try { + state.status = await api('GET', '/api/status'); + state.login = state.status.login; + renderPill(); + emit(); + } catch (e) { fail(e); } +} + +function connectEvents() { + const es = new EventSource('/api/events'); + es.addEventListener('status', (e) => { + if (!state.status) return; + state.status.daemon = JSON.parse(e.data); + renderPill(); + emit(); + }); + es.addEventListener('log', (e) => { + const l = JSON.parse(e.data); + if (l.seq <= state.logSeq) return; + state.logSeq = l.seq; + state.logs.push(l); + if (state.logs.length > 2000) state.logs.shift(); + logSink?.(l); + }); + es.addEventListener('login', (e) => { state.login = JSON.parse(e.data); emit(); }); + es.addEventListener('install', (e) => { const p = JSON.parse(e.data); if (p.phase === 'extract') toast('Verified checksum, installing…'); }); + es.onerror = () => { /* EventSource reconnects on its own */ }; +} + +function daemonState() { + const s = state.status; + if (!s) return 'stopped'; + if (s.daemon.state === 'stopped' && s.external) return 'external'; + if (s.daemon.state === 'crashed' && s.daemon.needsLogin) return 'not connected'; + return s.daemon.state; +} + +function renderPill() { + const st = daemonState(); + const label = { running: 's3d running', starting: 'Starting…', stopping: 'Stopping…', crashed: 's3d stopped (error)', 'not connected': 'Not connected to Sia', stopped: 's3d stopped', external: 's3d running (external)' }[st]; + const pill = $('#daemon-pill'); + pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`; + pill.querySelector('.label').textContent = label; +} + +// ---------------------------------------------------------------- router + +let cleanup = []; +let logSink = null; +const views = { overview, buckets, users, setup, settings, logs }; + +function route() { + cleanup.forEach((fn) => fn()); + cleanup = []; + logSink = null; + const [, name = 'overview', ...rest] = location.hash.replace(/^#/, '').split('/'); + const view = views[name] ? name : 'overview'; + for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view); + const main = $('#main'); + put(main); + views[view](main, rest.map(decodeURIComponent)); +} + +// ---------------------------------------------------------------- overview + +function overview(main) { + const daemonCard = h('div', { class: 'card' }); + const statsCard = h('div', { class: 'card' }); + const banners = h('div'); + main.append( + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Overview'), h('p', { class: 'muted' }, 'Your s3d gateway and its upload pipeline to Sia.'))), + banners, + h('div', { class: 'grid two' }, daemonCard, statsCard), + ); + + function draw() { + const s = state.status; + if (!s) return; + put(banners, ...bannersFor(s)); + const st = daemonState(); + const busy = st === 'starting' || st === 'stopping'; + const running = st === 'running' || st === 'starting'; + put(daemonCard, + h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), + h('dl', { class: 'kv' }, + h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)), + s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))], + h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')), + h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : '–'), + h('dt', {}, 'Config'), h('dd', {}, h('code', {}, s.config.file)), + h('dt', {}, 'Data'), h('dd', {}, h('code', {}, s.config.directory)), + ), + h('div', { class: 'row', style: 'margin-top:16px' }, + st === 'external' + ? h('span', { class: 'muted small' }, 'Another s3d process is serving this config. Stop it to manage it from here.') + : [ + h('button', { class: 'btn primary', disabled: busy || running || !s.daemon.binary, onclick: () => daemonAction('start') }, 'Start'), + h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'), + h('button', { class: 'btn danger', disabled: busy || !running, onclick: () => daemonAction('stop') }, 'Stop'), + ], + ), + ); + } + + async function drawStats() { + const st = daemonState(); + if (st !== 'running' && st !== 'external') { + put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'muted' }, 'Start s3d to see live upload stats.')); + return; + } + try { + const x = await api('GET', '/api/stats'); + const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l)); + put(statsCard, + h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'), + h('button', { class: 'btn small', disabled: !x.pendingObjects, title: 'Upload everything waiting in the local buffer now, even if slabs are not full', onclick: flushNow }, 'Flush now')), + h('div', { class: 'stats' }, + stat(x.pendingObjects, `pending · ${fmtBytes(x.pendingSize)}`), + stat(x.uploadedObjects, `on Sia · ${fmtBytes(x.uploadedSize)}`), + stat(x.unpinnedObjects, 'awaiting pin'), + stat(x.multipartUploads, 'open multipart'), + stat(x.failedUploads, 'failed uploads', x.failedUploads > 0), + stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0), + ), + h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'), + ); + } catch (e) { + put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message)); + } + } + + async function flushNow(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + put(btn, h('span', { class: 'spinner' }), ' Flushing…'); + try { await api('POST', '/api/flush'); toast('All pending objects are on Sia'); } + catch (e) { fail(e); } + drawStats(); + } + + draw(); + drawStats(); + cleanup.push(onState(draw)); + let lastState = daemonState(); + cleanup.push(onState(() => { if (daemonState() !== lastState) { lastState = daemonState(); drawStats(); } })); + const t = setInterval(() => { draw(); drawStats(); }, 4000); + cleanup.push(() => clearInterval(t)); +} + +function bannersFor(s) { + const out = []; + if (!s.daemon.binary) { + out.push(h('div', { class: 'banner warn' }, + h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`), + s.s3d.installable && h('button', { class: 'btn primary', onclick: installBinary }, `Install s3d v${s.s3d.pinned}`))); + } else if (s.s3d?.outdated) { + out.push(h('div', { class: 'banner warn' }, + h('span', {}, `s3d v${s.s3d.version} is older than v${s.s3d.pinned}. User and key listing needs v0.2.0 or newer.`), + s.s3d.installable && h('button', { class: 'btn', onclick: installBinary }, `Install v${s.s3d.pinned}`))); + } + if (s.registration?.registered === false || s.daemon.needsLogin) { + out.push(h('div', { class: 'banner info' }, + h('span', {}, 'This s3d is not connected to a Sia indexer yet.'), + h('a', { class: 'btn primary', href: '#/setup' }, 'Connect to Sia'))); + } + return out; +} + +async function installBinary(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + put(btn, h('span', { class: 'spinner' }), ' Downloading…'); + try { + const r = await api('POST', '/api/binary/install'); + toast(`Installed s3d v${r.version?.version || ''}`); + } catch (e) { fail(e); } + refreshStatus(); +} + +async function daemonAction(action) { + try { + await api('POST', `/api/daemon/${action}`); + await refreshStatus(); + } catch (e) { fail(e); } +} + +// ---------------------------------------------------------------- setup (indexer login) + +function setup(main) { + const body = h('div', { class: 'card' }); + main.append( + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'), + h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))), + body, + ); + + let draft = { indexerUrl: 'https://sia.storage', phrase: '', mode: 'existing' }; + + function draw() { + let l = state.login; + // Registered already (e.g. on an earlier run): show that instead of the form. + if (l.state === 'idle' && state.status?.registration?.registered) l = { state: 'already', indexerUrl: state.status.registration.indexerUrl }; + const step = ['idle', 'failed'].includes(l.state) ? 0 : ['done', 'already'].includes(l.state) ? 2 : 1; + const steps = h('div', { class: 'steps' }, ['Recovery phrase', 'Approve on the indexer', 'Connected'].map((t, i) => h('span', { class: i === step ? 'on' : '' }, `${i + 1}. ${t}`))); + + if (l.state === 'done' || l.state === 'already') { + put(body, steps, + h('h2', {}, l.state === 'done' ? 'Connected' : 'Already connected'), + h('p', {}, l.indexerUrl ? ['This s3d is registered with ', h('code', {}, l.indexerUrl), '.'] : 'This s3d is registered with an indexer.'), + h('div', { class: 'row' }, + daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'), + h('a', { class: 'btn', href: '#/users' }, 'Create a user'), + h('button', { class: 'btn', onclick: () => api('POST', '/api/login/cancel').catch(fail) }, 'Done'))); + return; + } + + if (l.state === 'confirm') { + const words = (l.generatedPhrase || '').split(/\s+/); + const ack = h('input', { type: 'checkbox', id: 'ack' }); + const go = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'Continue'); + ack.addEventListener('change', () => { go.disabled = !ack.checked; }); + put(body, steps, + h('h2', {}, 'Write down your new recovery phrase'), + h('p', {}, 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.'), + h('div', { class: 'phrase' }, words.map((w) => h('span', {}, w))), + h('div', { class: 'row', style: 'margin-top:14px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied - clear your clipboard after storing it') }, 'Copy')), + h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored the phrase somewhere safe'), + h('div', { class: 'row' }, go, h('button', { class: 'btn', onclick: cancel }, 'Cancel'))); + return; + } + + if (l.state === 'terminal') { + put(body, steps, + h('h2', {}, 'Finish in the console window'), + h('p', {}, 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.'), + h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'), + h('div', { class: 'row' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel'))); + return; + } + + if (step === 1) { + const waiting = l.state === 'approve' ? null : h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Talking to the indexer…'); + put(body, steps, + h('h2', {}, 'Approve the connection'), + waiting, + l.approvalUrl && [ + h('p', {}, 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.'), + h('div', { class: 'secret' }, h('code', {}, l.approvalUrl)), + h('div', { class: 'row', style: 'margin-top:12px' }, + h('button', { class: 'btn primary', onclick: () => openLink(l.approvalUrl) }, 'Open approval page'), + h('button', { class: 'btn', onclick: () => copy(l.approvalUrl) }, 'Copy link')), + h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for approval…'), + ], + h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel'))); + return; + } + + // step 0: form + const url = h('input', { type: 'url', value: draft.indexerUrl, required: true, oninput: (e) => { draft.indexerUrl = e.target.value; } }); + const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { draft.phrase = e.target.value; } }); + phrase.value = draft.phrase; + const radios = h('div', { class: 'stack' }, + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'existing', onchange: () => { draft.mode = 'existing'; draw(); } }), 'I have a recovery phrase'), + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'new', onchange: () => { draft.mode = 'new'; draw(); } }), 'Generate a new one')); + put(body, steps, + l.state === 'failed' && h('div', { class: 'banner warn' }, h('span', {}, l.error || 'Login failed')), + h('form', { class: 'stack', onsubmit: start }, + h('label', { class: 'field' }, h('span', {}, 'Indexer'), url, h('small', {}, 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.')), + radios, + draft.mode === 'existing' && h('label', { class: 'field' }, h('span', {}, 'Recovery phrase'), phrase, + h('small', {}, 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.')), + h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect')))); + } + + async function start(e) { + e.preventDefault(); + const phrase = draft.mode === 'existing' ? draft.phrase.trim() : ''; + if (draft.mode === 'existing' && phrase.split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error'); + try { + await api('POST', '/api/login', { indexerUrl: draft.indexerUrl, phrase }); + draft.phrase = ''; + } catch (err) { fail(err); } + } + + async function cancel() { try { await api('POST', '/api/login/cancel'); } catch (e) { fail(e); } } + + draw(); + cleanup.push(onState(draw)); +} + +async function openLink(url) { + const host = state.status?.host; + if (host === 'desktop' || host === 'theseus') { + try { await api('POST', '/api/open-external', { url }); return; } catch { /* fall through */ } + } + window.open(url, '_blank', 'noopener'); +} + +// ---------------------------------------------------------------- users & keys + +function users(main) { + const list = h('div', { class: 'card' }); + main.append( + h('div', { class: 'page-head' }, + h('div', {}, h('h1', {}, 'Users & keys'), h('p', { class: 'muted' }, 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.')), + h('button', { class: 'btn primary', onclick: createUser }, 'New user')), + list, + ); + + let expanded = new Set(); + + async function load() { + let rows; + try { rows = await api('GET', '/api/users'); } + catch (e) { put(list, h('p', { class: 'error' }, e.message)); return; } + if (!rows.length) { + put(list, h('div', { class: 'empty' }, h('p', {}, 'No users yet.'), h('button', { class: 'btn primary', onclick: createUser }, 'Create the first user'))); + return; + } + const keys = await api('GET', '/api/keys').catch(() => []); + put(list, h('table', {}, + h('thead', {}, h('tr', {}, h('th', {}, 'User'), h('th', {}, 'Access keys'), h('th', {}))), + h('tbody', {}, rows.flatMap((u) => { + const open = expanded.has(u.name); + const userKeys = keys.filter((k) => k.user === u.name); + const main = h('tr', { class: 'clickable', onclick: () => { open ? expanded.delete(u.name) : expanded.add(u.name); load(); } }, + h('td', {}, h('strong', {}, u.name)), + h('td', {}, `${u.keys.length}`), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Buckets'), + ' ', + h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), + ' ', + 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)' }, + userKeys.length ? h('table', {}, h('tbody', {}, userKeys.map((k) => h('tr', {}, + h('td', {}, h('code', {}, k.accessKeyId)), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: () => showKey(k) }, 'Show secret & config'), + ' ', + h('button', { class: 'btn small danger', onclick: () => deleteKey(k.accessKeyId) }, 'Revoke')))))) + : h('p', { class: 'muted', style: 'margin:0' }, 'No keys. Create one to use S3 clients or browse buckets.'))); + return [main, detail]; + })))); + } + + async function createUser() { + const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' }); + const withKey = h('input', { type: 'checkbox', checked: true }); + const v = await modal('New user', h('div', { class: 'stack' }, + h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')), + h('label', { class: 'check' }, withKey, 'Create an access key now')), + [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked }) }]); + if (!v) return; + try { + const r = await api('POST', '/api/users', v); + toast(`Created ${r.name}`); + expanded.add(r.name); + if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); } + await load(); + if (r.key) showKey(r.key, true); + } catch (e) { fail(e); } + } + + async function newKey(user) { + try { + const k = await api('POST', '/api/keys', { user }); + expanded.add(user); + await load(); + showKey(k, true); + } catch (e) { fail(e); } + } + + 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); } + } + + async function deleteKey(id) { + if (!(await confirmModal('Revoke this key?', `Clients using ${id} will be refused from now on.`, 'Revoke'))) return; + try { await api('DELETE', `/api/keys/${encodeURIComponent(id)}`); toast('Key revoked'); load(); } catch (e) { fail(e); } + } + + load(); +} + +function showKey(k, fresh) { + const s = state.status; + const endpoint = `http://${s?.config.apiAddress || '127.0.0.1:8000'}`; + const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user} +aws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user} +aws configure set region us-east-1 --profile ${k.user} +aws --profile ${k.user} --endpoint-url ${endpoint} s3 ls`; + const rclone = `[sia-${k.user}] +type = s3 +provider = Other +access_key_id = ${k.accessKeyId} +secret_access_key = ${k.secretKey} +endpoint = ${endpoint} +region = us-east-1 +force_path_style = true`; + const env = `AWS_ACCESS_KEY_ID=${k.accessKeyId} +AWS_SECRET_ACCESS_KEY=${k.secretKey} +AWS_REGION=us-east-1 +AWS_ENDPOINT_URL=${endpoint}`; + const tabs = { 'aws cli': aws, rclone, env }; + const pre = h('pre', { class: 'snippet' }, aws); + const tabBtns = h('div', { class: 'row' }, Object.keys(tabs).map((t, i) => h('button', { + type: 'button', class: `btn small ${i === 0 ? 'primary' : ''}`, + onclick: (e) => { for (const b of tabBtns.children) b.classList.remove('primary'); e.currentTarget.classList.add('primary'); pre.textContent = tabs[t]; }, + }, t))); + modal(fresh ? `Access key for ${k.user}` : `Key ${k.accessKeyId}`, h('div', { class: 'stack' }, + 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('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 }]); +} + +// ---------------------------------------------------------------- buckets & objects + +function buckets(main, [bucket, ...prefixParts]) { + const head = h('div', { class: 'page-head' }); + const body = h('div'); + main.append(head, body); + + let users = []; + const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); location.hash = '#/buckets'; route(); } }); + + (async () => { + try { users = await api('GET', '/api/users'); } + catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; } + const usable = users.filter((u) => u.keys.length); + if (!usable.length) { + put(head, h('div', {}, h('h1', {}, 'Buckets'))); + put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys'))); + return; + } + if (!usable.some((u) => u.name === state.selectedUser)) state.selectedUser = usable[0].name; + put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name))); + const st = daemonState(); + if (st !== 'running' && st !== 'external') { + put(head, h('div', {}, h('h1', {}, 'Buckets'))); + put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d'))); + return; + } + if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel); + else bucketList(head, body, state.selectedUser, userSel); + })(); +} + +function userPicker(userSel) { + return h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Acting as'), h('span', { style: 'width:180px' }, userSel)); +} + +async function bucketList(head, body, user, userSel) { + put(head, + h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Buckets owned by the selected user.')), + h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket'))); + const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); + put(body, card); + const base = `/api/s3/${encodeURIComponent(user)}/buckets`; + + async function load() { + try { + const list = await api('GET', base); + if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No buckets yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a bucket'))); return; } + put(card, h('table', {}, + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Created'), h('th', {}, 'Access'), h('th', {}))), + h('tbody', {}, list.map((b) => { + const access = h('td', {}, h('span', { class: 'badge' }, '…')); + api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => { + put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: 'private', read: 'public read', 'read-list': 'public read + list', custom: 'custom policy' }[p.public])); + }).catch(() => put(access, h('span', { class: 'badge' }, '?'))); + return h('tr', { class: 'clickable', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, + h('td', {}, h('strong', {}, b.name)), + h('td', { class: 'hide-sm muted' }, fmtDate(b.created)), + access, + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), + ' ', + h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete'))); + })))); + } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } + } + + async function create() { + const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-bucket' }); + const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')), + [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]); + if (!v) return; + try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); } + } + + async function remove(name) { + if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return; + try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); } + } + + load(); +} + +async function accessDialog(user, bucket) { + const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`; + let current = 'private'; + try { current = (await api('GET', base)).public; } catch (e) { return fail(e); } + const opts = [ + ['private', 'Private', 'Only this user\'s access keys can read.'], + ['read', 'Public read', 'Anyone who knows an object\'s URL can download it.'], + ['read-list', 'Public read + list', 'Anyone can also list the bucket\'s contents.'], + ]; + let choice = current === 'custom' ? 'private' : current; + const endpoint = `http://${state.status?.config.apiAddress}`; + const v = await modal(`Access for ${bucket}`, h('div', { class: 'stack' }, + current === 'custom' && h('div', { class: 'banner warn' }, 'This bucket has a custom policy. Saving replaces it.'), + opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' }, + h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }), + h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))), + h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. Writes and deletes always need a key.')), + [{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]); + if (!v || v === current) return; + try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); } +} + +function objectBrowser(head, body, user, bucket, prefix, userSel) { + if (prefix && !prefix.endsWith('/')) prefix += '/'; + const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`; + const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; }; + + const crumbs = h('div', { class: 'crumbs' }, + h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Buckets'), h('span', { class: 'sep' }, '/'), + h('button', { onclick: () => go('') }, bucket), + prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)])); + + const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } }); + put(head, + h('div', { class: 'stack' }, crumbs), + h('div', { class: 'row' }, userPicker(userSel), + h('button', { class: 'btn', onclick: newFolder }, 'New folder'), + h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'), + h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput)); + + const uploads = h('div', { class: 'uploads' }); + const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…')); + put(body, uploads, card); + + let depth = 0; + card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); }); + card.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; card.classList.remove('over'); } }); + card.addEventListener('dragover', (e) => e.preventDefault()); + card.addEventListener('drop', (e) => { + e.preventDefault(); depth = 0; card.classList.remove('over'); + uploadFiles([...e.dataTransfer.files]); + }); + + let pages = []; + async function load(token) { + try { + const q = new URLSearchParams({ prefix }); + if (token) q.set('token', token); + const page = await api('GET', `${base}/objects?${q}`); + pages = token ? [...pages, page] : [page]; + draw(); + } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } + } + + function draw() { + const folders = pages.flatMap((p) => p.folders); + const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix); + const last = pages[pages.length - 1]; + if (!folders.length && !objects.length) { + put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This bucket is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); + return; + } + put(card, h('table', {}, + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), + h('tbody', {}, + folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) }, + h('td', {}, '📁 ', f.slice(prefix.length)), + h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }), + h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))), + objects.map((o) => h('tr', {}, + h('td', {}, h('a', { href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '' }, o.key.slice(prefix.length))), + h('td', { class: 'num' }, fmtBytes(o.size)), + h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), + ' ', + h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), + last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'))); + } + + function uploadFiles(files) { + for (const file of files) { + if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; } + const key = prefix + file.name; + const bar = h('i'); + const pct = h('span', { class: 'muted' }, '0%'); + const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct); + uploads.append(row); + const xhr = new XMLHttpRequest(); + xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`); + xhr.setRequestHeader('x-pithos', '1'); + if (file.type) xhr.setRequestHeader('content-type', file.type); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); bar.style.width = p + '%'; pct.textContent = p + '%'; } }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { pct.textContent = 'done'; setTimeout(() => row.remove(), 1500); load(); } + else { pct.textContent = 'failed'; pct.className = 'error'; let m = xhr.statusText; try { m = JSON.parse(xhr.responseText).error; } catch {} toast(`${file.name}: ${m}`, 'error'); } + }; + xhr.onerror = () => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: network error`, 'error'); }; + xhr.send(file); + } + } + + async function newFolder() { + const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' }); + const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'S3 has no real folders. This opens an empty prefix; it appears once you upload into it.')), + [{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => name.value.trim() }]); + if (v) go(prefix + v); + } + + async function deleteObject(key) { + if (!(await confirmModal('Delete object?', key))) return; + try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } + } + + async function deleteFolder(p) { + if (!(await confirmModal('Delete folder?', `Every object under ${p} will be deleted.`))) return; + try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); } + } + + async function share(key) { + const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l))); + const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || ''; + const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` }); + const out = h('div'); + const gen = async () => { + const q = new URLSearchParams({ key, expires: expires.value }); + if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); } + try { + const { url } = await api('GET', `${base}/presign?${q}`); + put(out, secretRow('Link', url)); + } catch (e) { fail(e); } + return false; + }; + await modal('Share link', h('div', { class: 'stack' }, + h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'), + h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), + h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')), + out), + [{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]); + } + + load(); +} + +// ---------------------------------------------------------------- settings + +function settings(main) { + const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); + main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card); + + const fields = [ + ['Network', [ + ['apiAddress', 'S3 API address', 'text', 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.'], + ['apiHTTPSAddress', 'S3 HTTPS address', 'text', 'Optional. s3d serves a self-signed certificate here.'], + ['adminAddress', 'Admin API address', 'text', 'Pithos reads stats from here. Keep it on loopback.'], + ['adminPassword', 'Admin API password', 'password', 'Generated on first start.'], + ['s3.hostBases', 'Virtual-host bases', 'list', 'Comma-separated domains for bucket.domain style addressing.'], + ]], + ['Storage', [ + ['directory', 'Data directory', 'text', 'Database and upload buffer. Changing it points s3d at a different store.'], + ['sia.diskUsageLimit', 'Buffer disk limit (bytes)', 'number', 'Upper bound for objects waiting to upload.'], + ['sia.uploadThreads', 'Upload threads', 'number', ''], + ['sia.dataShards', 'Data shards', 'number', 'Erasure coding: data shards (default 10). Applies to new uploads only.'], + ['sia.parityShards', 'Parity shards', 'number', 'Erasure coding: parity shards (default 20).'], + ]], + ['Logging', [ + ['log.stdout.level', 'Console log level', 'select:debug,info,warn,error', ''], + ['log.file.enabled', 'Write a log file', 'bool', ''], + ['log.file.level', 'File log level', 'select:debug,info,warn,error', ''], + ]], + ]; + + (async () => { + let cfg; + try { cfg = await api('GET', '/api/config'); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); return; } + const inputs = {}; + const sections = fields.map(([title, list]) => h('div', { class: 'stack', style: 'margin-bottom:22px' }, h('h2', {}, title), h('div', { class: 'form-grid' }, list.map(([key, label, type, help]) => { + const v = cfg.values[key]; + let input; + if (type === 'bool') input = h('input', { type: 'checkbox', checked: !!v }); + else if (type.startsWith('select:')) input = h('select', {}, h('option', { value: '' }, '(default)'), type.slice(7).split(',').map((o) => h('option', { value: o, selected: v === o }, o))); + else input = h('input', { type: type === 'list' ? 'text' : type, value: Array.isArray(v) ? v.join(', ') : (v ?? ''), placeholder: '(default)', autocomplete: 'off' }); + inputs[key] = { input, type, orig: v }; + return type === 'bool' + ? h('label', { class: 'check' }, input, label) + : h('label', { class: 'field' }, h('span', {}, label), input, help && h('small', {}, help)); + })))); + put(card, + h('p', { class: 'small muted' }, 'File: ', h('code', {}, cfg.file)), + ...sections, + h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: save }, 'Save'))); + + async function save() { + const patch = {}; + for (const [key, { input, type, orig }] of Object.entries(inputs)) { + let v; + if (type === 'bool') v = input.checked; + else if (type === 'number') v = input.value === '' ? null : Number(input.value); + else if (type === 'list') v = input.value.split(',').map((s) => s.trim()).filter(Boolean); + else v = input.value.trim() || null; + const same = JSON.stringify(v ?? null) === JSON.stringify(orig ?? null) || (type === 'list' && !v.length && orig == null) || (type === 'bool' && !v && orig == null); + if (!same) patch[key] = v; + } + if (!Object.keys(patch).length) return toast('Nothing changed'); + try { + const r = await api('PUT', '/api/config', patch); + for (const k of Object.keys(patch)) inputs[k].orig = patch[k]; + if (r.restartNeeded) { + if (await confirmModal('Restart s3d?', 'The new settings apply after a restart.', 'Restart now', 'primary')) await daemonAction('restart'); + } else toast('Saved'); + refreshStatus(); + } catch (e) { fail(e); } + } + })(); +} + +// ---------------------------------------------------------------- logs + +function logs(main) { + const box = h('div', { class: 'logs' }); + let follow = true; + box.addEventListener('scroll', () => { follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 20; }); + main.append( + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Logs'), h('p', { class: 'muted' }, 'Output from the s3d process Pithos started.')), + h('button', { class: 'btn', onclick: () => copy(state.logs.map((l) => l.line).join('\n'), 'Logs copied') }, 'Copy all')), + box); + const line = (l) => h('div', { class: l.stream }, l.line); + box.append(...state.logs.map(line)); + if (!state.logs.length) box.append(h('div', { class: 'sys' }, 'No output yet.')); + box.scrollTop = box.scrollHeight; + logSink = (l) => { + box.append(line(l)); + while (box.childElementCount > 2000) box.firstChild.remove(); + if (follow) box.scrollTop = box.scrollHeight; + }; +} + +// ---------------------------------------------------------------- boot + +function showSignin() { + $('#app').hidden = true; + $('#signin').hidden = false; +} + +async function boot() { + const s = await api('GET', '/api/session'); + if (!s.authenticated) { + showSignin(); + if (!s.passwordLogin) { + $('#signin-hint').textContent = 'Open Pithos with the sign-in link printed when it started.'; + $('#signin-form').querySelector('input').hidden = true; + $('#signin-form').querySelector('button').hidden = true; + } + return; + } + $('#signin').hidden = true; + $('#app').hidden = false; + // Backfill logs the server already holds, then stream. + try { + state.logs = await api('GET', '/api/logs'); + state.logSeq = state.logs.at(-1)?.seq || 0; + } catch {} + await refreshStatus(); + connectEvents(); + window.addEventListener('hashchange', route); + route(); + setInterval(refreshStatus, 15000); +} + +$('#signin-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const err = $('#signin-error'); + err.hidden = true; + try { + await api('POST', '/api/session', { password: $('#signin-password').value }); + location.reload(); + } catch (ex) { err.textContent = ex.message; err.hidden = false; } +}); + +boot().catch(fail); diff --git a/ui/icon.svg b/ui/icon.svg new file mode 100644 index 0000000..4335a25 --- /dev/null +++ b/ui/icon.svg @@ -0,0 +1 @@ + diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..6a37b80 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,42 @@ + + + + + +Pithos + + + + + + + + + +
+ + +