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.
This commit is contained in:
Local Dev 2026-10-03 19:02:50 +02:00
commit 6b8eeaf82b
24 changed files with 3693 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
bin/s3d
bin/s3d.exe
dist/
.dev/

373
LICENSE Normal file
View file

@ -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.

130
README.md Normal file
View file

@ -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.yml>`, `--s3d <binary>`, `--bin-dir <dir>`,
`--password-file <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.

78
bin/pithos-web.js Normal file
View file

@ -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.yml>, --s3d <binary>, --bin-dir <dir>,
// --password-file <file>, --allow-host <name[:port]> (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]];
}

34
core/admin.js Normal file
View file

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

77
core/binary.js Normal file
View file

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

93
core/cli.js Normal file
View file

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

79
core/config.js Normal file
View file

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

117
core/daemon.js Normal file
View file

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

185
core/login.js Normal file
View file

@ -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 {} }
}
}

49
core/paths.js Normal file
View file

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

221
core/s3.js Normal file
View file

@ -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]*?)</${name}>`, '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]*?)</${name}>`));
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));
}
});
}

474
core/server.js Normal file
View file

@ -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>(.*?)<\/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=<secret> 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);
});
}

77
desktop/main.cjs Normal file
View file

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

295
package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

24
package.json Normal file
View file

@ -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"
}
}

View file

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

79
test/sigv4.test.js Normal file
View file

@ -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('<?xml version="1.0"?><ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>');
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'));
});

27
theseus/addon.json Normal file
View file

@ -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"
}

57
theseus/index.js Normal file
View file

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

211
ui/app.css Normal file
View file

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

916
ui/app.js Normal file
View file

@ -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}/<key>`), '. 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);

1
ui/icon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3ddc97"/><stop offset="1" stop-color="#1e8f6a"/></linearGradient></defs><path d="M22 6h20v5c0 2-2 3-2 5 8 3 14 11 14 21 0 12-9 21-22 21S10 49 10 37c0-10 6-18 14-21 0-2-2-3-2-5z" fill="url(#g)"/><path d="M14 30h36M13 40h38" stroke="#0b1f18" stroke-width="3" stroke-linecap="round" opacity=".55"/></svg>

After

Width:  |  Height:  |  Size: 449 B

42
ui/index.html Normal file
View file

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pithos</title>
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="app.css">
</head>
<body>
<div id="signin" class="signin" hidden>
<form class="card signin-card" id="signin-form">
<img src="icon.svg" alt="" width="48" height="48">
<h1>Pithos</h1>
<p class="muted" id="signin-hint">Enter the console password.</p>
<input type="password" id="signin-password" autocomplete="current-password" placeholder="Password" required>
<button class="btn primary" type="submit">Sign in</button>
<p class="error" id="signin-error" hidden></p>
</form>
</div>
<div id="app" class="app" hidden>
<aside class="sidebar">
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span></div>
<nav id="nav">
<a href="#/overview" data-view="overview">Overview</a>
<a href="#/buckets" data-view="buckets">Buckets</a>
<a href="#/users" data-view="users">Users &amp; keys</a>
<a href="#/setup" data-view="setup">Sia connection</a>
<a href="#/settings" data-view="settings">Settings</a>
<a href="#/logs" data-view="logs">Logs</a>
</nav>
<div class="daemon-pill" id="daemon-pill"><span class="dot"></span><span class="label">…</span></div>
</aside>
<main id="main"></main>
</div>
<div id="modal-root"></div>
<div id="toasts" class="toasts" aria-live="polite"></div>
<script type="module" src="app.js"></script>
</body>
</html>