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:
commit
6b8eeaf82b
24 changed files with 3693 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
bin/s3d
|
||||
bin/s3d.exe
|
||||
dist/
|
||||
.dev/
|
||||
373
LICENSE
Normal file
373
LICENSE
Normal 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
130
README.md
Normal 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
78
bin/pithos-web.js
Normal 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
34
core/admin.js
Normal 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
77
core/binary.js
Normal 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
93
core/cli.js
Normal 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
79
core/config.js
Normal 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
117
core/daemon.js
Normal 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
185
core/login.js
Normal 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
49
core/paths.js
Normal 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
221
core/s3.js
Normal 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
474
core/server.js
Normal 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
77
desktop/main.cjs
Normal 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
295
package-lock.json
generated
Normal 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
24
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
scripts/build-theseus-addon.mjs
Normal file
49
scripts/build-theseus-addon.mjs
Normal 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
79
test/sigv4.test.js
Normal 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
27
theseus/addon.json
Normal 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
57
theseus/index.js
Normal 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
211
ui/app.css
Normal 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
916
ui/app.js
Normal 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
1
ui/icon.svg
Normal 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
42
ui/index.html
Normal 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 & 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>
|
||||
Loading…
Add table
Reference in a new issue