Theseus: bundle Pithos 0.3.11
New installs carry the same Pithos the extension channel serves, including drives on Silent Mode and the Sia account card.
This commit is contained in:
parent
399e763745
commit
d355bbbf87
12 changed files with 2057 additions and 30 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "pithos",
|
"id": "pithos",
|
||||||
"name": "Pithos",
|
"name": "Pithos",
|
||||||
"version": "0.3.10",
|
"version": "0.3.11",
|
||||||
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ const ARCHIVE_PREFIX = 'previous-account-';
|
||||||
const META = 'pithos-archive.json';
|
const META = 'pithos-archive.json';
|
||||||
const LABEL_FILE = 'pithos.json';
|
const LABEL_FILE = 'pithos.json';
|
||||||
|
|
||||||
const keep = (name) => name === 's3d.yml' || name.startsWith(ARCHIVE_PREFIX);
|
// Files that belong to this computer, not to a Sia account. The hosted key
|
||||||
|
// file above all: moving or deleting it would make encrypted files unreadable.
|
||||||
|
const LOCAL = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json']);
|
||||||
|
const keep = (name) => LOCAL.has(name) || name.startsWith('pithos-hosted-secret') || name.startsWith(ARCHIVE_PREFIX);
|
||||||
|
|
||||||
function stamp(d = new Date()) {
|
function stamp(d = new Date()) {
|
||||||
const p = (n) => String(n).padStart(2, '0');
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
|
@ -48,6 +51,7 @@ export function listArchives(dataDir) {
|
||||||
indexerUrl: meta.indexerUrl || null,
|
indexerUrl: meta.indexerUrl || null,
|
||||||
label: meta.label || readLabel(dir),
|
label: meta.label || readLabel(dir),
|
||||||
users: meta.users || [],
|
users: meta.users || [],
|
||||||
|
movedTo: meta.movedTo || null,
|
||||||
hasData: fs.existsSync(path.join(dir, 's3d.db')),
|
hasData: fs.existsSync(path.join(dir, 's3d.db')),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -76,6 +80,11 @@ export function restoreArchive(dataDir, name, metaForCurrent = {}) {
|
||||||
if (!name.startsWith(ARCHIVE_PREFIX) || name.includes('/') || name.includes('\\') || name.includes('..')) throw new Error('not an account archive');
|
if (!name.startsWith(ARCHIVE_PREFIX) || name.includes('/') || name.includes('\\') || name.includes('..')) throw new Error('not an account archive');
|
||||||
const src = path.join(dataDir, name);
|
const src = path.join(dataDir, name);
|
||||||
if (!fs.existsSync(path.join(src, 's3d.db'))) throw new Error('that archive has no s3d data');
|
if (!fs.existsSync(path.join(src, 's3d.db'))) throw new Error('that archive has no s3d data');
|
||||||
|
let meta = {};
|
||||||
|
try { meta = JSON.parse(fs.readFileSync(path.join(src, META), 'utf8')); } catch {}
|
||||||
|
// The copy left behind when drives moved to Silent Mode is out of date the
|
||||||
|
// moment the server s3d writes anything; running it would fork the account.
|
||||||
|
if (meta.movedTo) throw new Error(`these drives now live on ${meta.movedTo}; move them back from Settings instead`);
|
||||||
const archivedCurrent = fs.existsSync(path.join(dataDir, 's3d.db')) ? archiveCurrent(dataDir, metaForCurrent).name : null;
|
const archivedCurrent = fs.existsSync(path.join(dataDir, 's3d.db')) ? archiveCurrent(dataDir, metaForCurrent).name : null;
|
||||||
// Anything left (tmp folders from a fresh, unconnected s3d) is clutter.
|
// Anything left (tmp folders from a fresh, unconnected s3d) is clutter.
|
||||||
for (const entry of fs.readdirSync(dataDir)) {
|
for (const entry of fs.readdirSync(dataDir)) {
|
||||||
|
|
|
||||||
81
bundled-addons/pithos/core/autoflush.js
Normal file
81
bundled-addons/pithos/core/autoflush.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// Optional automatic flush of leftovers. s3d packs uploads into full slabs
|
||||||
|
// (about 40 MB) before sending them to Sia, so a small batch can sit in the
|
||||||
|
// local buffer indefinitely. When the user opts in, this watches s3d's upload
|
||||||
|
// stats and flushes once the pending set has stopped changing for N minutes,
|
||||||
|
// i.e. nobody is still uploading into it.
|
||||||
|
//
|
||||||
|
// The watcher only sees stats, so it also catches uploads made by other S3
|
||||||
|
// clients, not just the ones that went through Pithos.
|
||||||
|
|
||||||
|
export const DEFAULT_MINUTES = 10;
|
||||||
|
export const MIN_MINUTES = 1;
|
||||||
|
export const MAX_MINUTES = 24 * 60;
|
||||||
|
|
||||||
|
export function clampMinutes(m) {
|
||||||
|
const n = Math.round(Number(m));
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_MINUTES;
|
||||||
|
return Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings: () => ({ enabled, minutes }); stats: async () => /stats/uploads;
|
||||||
|
// flush: async () => void; log: (line) => void.
|
||||||
|
export function createAutoFlush({ settings, stats, flush, log = () => {}, now = Date.now }) {
|
||||||
|
let signature = null; // what was pending last time we looked
|
||||||
|
let quietSince = 0;
|
||||||
|
let flushing = false;
|
||||||
|
let lastFlushAt = null;
|
||||||
|
let lastError = null;
|
||||||
|
let failures = 0; // in a row; each one doubles the wait, up to an hour
|
||||||
|
|
||||||
|
function reset() { signature = null; quietSince = 0; }
|
||||||
|
|
||||||
|
function waitMs(minutes) {
|
||||||
|
const base = clampMinutes(minutes) * 60_000;
|
||||||
|
return failures ? Math.max(base, Math.min(60 * 60_000, base * 2 ** failures)) : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const { enabled, minutes } = settings();
|
||||||
|
if (!enabled || flushing) { if (!enabled) reset(); return; }
|
||||||
|
let x;
|
||||||
|
try { x = await stats(); } catch { reset(); return; } // s3d not reachable
|
||||||
|
if (!x.pendingObjects) { reset(); return; }
|
||||||
|
const sig = `${x.pendingObjects}:${x.pendingSize}`;
|
||||||
|
if (sig !== signature) { signature = sig; quietSince = now(); return; }
|
||||||
|
if (now() - quietSince < waitMs(minutes)) return;
|
||||||
|
flushing = true;
|
||||||
|
log(`uploading ${x.pendingObjects} leftover object(s) to Sia after ${clampMinutes(minutes)} min with no new uploads`);
|
||||||
|
try {
|
||||||
|
await flush();
|
||||||
|
lastFlushAt = now();
|
||||||
|
lastError = null;
|
||||||
|
failures = 0;
|
||||||
|
log('leftovers are on Sia');
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e.message;
|
||||||
|
failures++;
|
||||||
|
log(`automatic flush failed: ${e.message}`);
|
||||||
|
quietSince = now(); // a broken indexer is retried less and less often
|
||||||
|
} finally {
|
||||||
|
flushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tick,
|
||||||
|
status() {
|
||||||
|
const { enabled, minutes } = settings();
|
||||||
|
const m = clampMinutes(minutes);
|
||||||
|
return {
|
||||||
|
enabled: !!enabled,
|
||||||
|
minutes: m,
|
||||||
|
flushing,
|
||||||
|
lastFlushAt,
|
||||||
|
lastError,
|
||||||
|
// When the next automatic flush happens if nothing new arrives.
|
||||||
|
dueAt: enabled && signature && !flushing ? quietSince + waitMs(m) : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
72
bundled-addons/pithos/core/blake2b.js
Normal file
72
bundled-addons/pithos/core/blake2b.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// BLAKE2b (RFC 7693) with a chosen digest length. Node's OpenSSL only offers
|
||||||
|
// BLAKE2b-512, and BLAKE2b-256 is not a truncation of it (the length is part
|
||||||
|
// of the parameter block). Sia hashes with BLAKE2b-256 everywhere; Pithos only
|
||||||
|
// needs it for short messages (signing indexer requests), so this favours
|
||||||
|
// clarity over speed.
|
||||||
|
|
||||||
|
const MASK = (1n << 64n) - 1n;
|
||||||
|
const IV = [
|
||||||
|
0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n,
|
||||||
|
0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n,
|
||||||
|
];
|
||||||
|
const SIGMA = [
|
||||||
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
|
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
||||||
|
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
|
||||||
|
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
||||||
|
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
|
||||||
|
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
||||||
|
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
|
||||||
|
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
||||||
|
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
|
||||||
|
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
const rotr = (x, n) => ((x >> n) | (x << (64n - n))) & MASK;
|
||||||
|
|
||||||
|
function compress(h, block, t, last) {
|
||||||
|
const m = [];
|
||||||
|
for (let i = 0; i < 16; i++) m.push(block.readBigUInt64LE(i * 8));
|
||||||
|
const v = [...h, ...IV];
|
||||||
|
v[12] ^= t & MASK;
|
||||||
|
v[13] ^= t >> 64n;
|
||||||
|
if (last) v[14] ^= MASK;
|
||||||
|
const g = (a, b, c, d, x, y) => {
|
||||||
|
v[a] = (v[a] + v[b] + x) & MASK; v[d] = rotr(v[d] ^ v[a], 32n);
|
||||||
|
v[c] = (v[c] + v[d]) & MASK; v[b] = rotr(v[b] ^ v[c], 24n);
|
||||||
|
v[a] = (v[a] + v[b] + y) & MASK; v[d] = rotr(v[d] ^ v[a], 16n);
|
||||||
|
v[c] = (v[c] + v[d]) & MASK; v[b] = rotr(v[b] ^ v[c], 63n);
|
||||||
|
};
|
||||||
|
for (let r = 0; r < 12; r++) {
|
||||||
|
const s = SIGMA[r % 10];
|
||||||
|
g(0, 4, 8, 12, m[s[0]], m[s[1]]); g(1, 5, 9, 13, m[s[2]], m[s[3]]);
|
||||||
|
g(2, 6, 10, 14, m[s[4]], m[s[5]]); g(3, 7, 11, 15, m[s[6]], m[s[7]]);
|
||||||
|
g(0, 5, 10, 15, m[s[8]], m[s[9]]); g(1, 6, 11, 12, m[s[10]], m[s[11]]);
|
||||||
|
g(2, 7, 8, 13, m[s[12]], m[s[13]]); g(3, 4, 9, 14, m[s[14]], m[s[15]]);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 8; i++) h[i] ^= v[i] ^ v[i + 8];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blake2b(data, outLen = 32) {
|
||||||
|
const input = Buffer.from(data);
|
||||||
|
const h = [...IV];
|
||||||
|
h[0] ^= 0x01010000n ^ BigInt(outLen);
|
||||||
|
let t = 0n;
|
||||||
|
let off = 0;
|
||||||
|
// Every block but the last is compressed as "not final"; an empty input is
|
||||||
|
// one zero block marked final.
|
||||||
|
while (input.length - off > 128) {
|
||||||
|
t += 128n;
|
||||||
|
compress(h, input.subarray(off, off + 128), t, false);
|
||||||
|
off += 128;
|
||||||
|
}
|
||||||
|
const last = Buffer.alloc(128);
|
||||||
|
input.copy(last, 0, off);
|
||||||
|
t += BigInt(input.length - off);
|
||||||
|
compress(h, last, t, true);
|
||||||
|
const out = Buffer.alloc(64);
|
||||||
|
h.forEach((x, i) => out.writeBigUInt64LE(x, i * 8));
|
||||||
|
return out.subarray(0, outLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blake2b256 = (data) => blake2b(data, 32);
|
||||||
349
bundled-addons/pithos/core/crypt.js
Normal file
349
bundled-addons/pithos/core/crypt.js
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
// Client-side encryption for drives hosted on Silent Mode. Everything here
|
||||||
|
// runs on the user's machine; the server only ever sees what comes out.
|
||||||
|
//
|
||||||
|
// Keys. One 32-byte master secret per person (from the Theseus vault, or a
|
||||||
|
// local key file on hosts without one). Each drive (bucket) gets its own keys
|
||||||
|
// from it, so a leaked drive key exposes one drive:
|
||||||
|
// HKDF(master, info "pithos/drive/v1/<bucket>") -> 64-byte name key + 32-byte body key
|
||||||
|
//
|
||||||
|
// Names. Every path segment is encrypted on its own with AES-SIV (RFC 5297),
|
||||||
|
// which is deterministic: the same name in the same folder always gives the
|
||||||
|
// same ciphertext, so prefix listing, folders and overwrite-by-name keep
|
||||||
|
// working on the server. The parent folder's plain path is associated data,
|
||||||
|
// so equal names in different folders do not look equal. An encrypted segment
|
||||||
|
// is "~" + base64url(SIV tag || ciphertext). SIV authenticates, so a segment
|
||||||
|
// that does not decrypt is simply a plain name (public files stay readable).
|
||||||
|
//
|
||||||
|
// Bodies. AES-256-GCM in 1 MiB chunks behind a 24-byte header:
|
||||||
|
// "PTE1" | salt (16) | log2(chunk size) (1) | 0 0 0
|
||||||
|
// The per-file key is HKDF(body key, salt). Chunk i's nonce is i (8 bytes BE)
|
||||||
|
// plus a last-chunk flag, so chunks cannot be reordered, dropped or cut off
|
||||||
|
// at the end. Each chunk's additional data is the header plus the drive and
|
||||||
|
// plain path, so the server cannot swap one file's contents for another's.
|
||||||
|
// Ciphertext size is a pure function of plaintext size and back, which keeps
|
||||||
|
// Content-Length known up front and lets range reads fetch only the chunks
|
||||||
|
// they need.
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
export const HEADER_LEN = 24;
|
||||||
|
export const TAG_LEN = 16;
|
||||||
|
const MAGIC = Buffer.from('PTE1');
|
||||||
|
const LOG2_CHUNK = 20; // 1 MiB
|
||||||
|
|
||||||
|
// ---- AES-SIV (RFC 5297), AES-CMAC (RFC 4493) ---------------------------
|
||||||
|
|
||||||
|
const ZERO = Buffer.alloc(16);
|
||||||
|
|
||||||
|
function aesEcb(key, block) {
|
||||||
|
const c = crypto.createCipheriv(`aes-${key.length * 8}-ecb`, key, null);
|
||||||
|
c.setAutoPadding(false);
|
||||||
|
return Buffer.concat([c.update(block), c.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbl(b) {
|
||||||
|
const out = Buffer.alloc(16);
|
||||||
|
let carry = 0;
|
||||||
|
for (let i = 15; i >= 0; i--) {
|
||||||
|
out[i] = ((b[i] << 1) | carry) & 0xff;
|
||||||
|
carry = b[i] >> 7;
|
||||||
|
}
|
||||||
|
if (b[0] & 0x80) out[15] ^= 0x87;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xor(a, b) {
|
||||||
|
const out = Buffer.alloc(a.length);
|
||||||
|
for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cmac(key, msg) {
|
||||||
|
const k1 = dbl(aesEcb(key, ZERO));
|
||||||
|
const k2 = dbl(k1);
|
||||||
|
const n = Math.max(1, Math.ceil(msg.length / 16));
|
||||||
|
const complete = msg.length > 0 && msg.length % 16 === 0;
|
||||||
|
const last = Buffer.alloc(16);
|
||||||
|
msg.copy(last, 0, (n - 1) * 16);
|
||||||
|
if (!complete) last[msg.length - (n - 1) * 16] = 0x80;
|
||||||
|
const m = Buffer.concat([msg.subarray(0, (n - 1) * 16), xor(last, complete ? k1 : k2)]);
|
||||||
|
const c = crypto.createCipheriv(`aes-${key.length * 8}-cbc`, key, ZERO);
|
||||||
|
c.setAutoPadding(false);
|
||||||
|
const out = Buffer.concat([c.update(m), c.final()]);
|
||||||
|
return out.subarray(out.length - 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function s2v(key, ads, plain) {
|
||||||
|
let d = cmac(key, ZERO);
|
||||||
|
for (const ad of ads) d = xor(dbl(d), cmac(key, ad));
|
||||||
|
let t;
|
||||||
|
if (plain.length >= 16) {
|
||||||
|
t = Buffer.from(plain);
|
||||||
|
const tail = t.length - 16;
|
||||||
|
for (let i = 0; i < 16; i++) t[tail + i] ^= d[i];
|
||||||
|
} else {
|
||||||
|
const padded = Buffer.alloc(16);
|
||||||
|
plain.copy(padded);
|
||||||
|
padded[plain.length] = 0x80;
|
||||||
|
t = xor(dbl(d), padded);
|
||||||
|
}
|
||||||
|
return cmac(key, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sivCtr(key, v, data) {
|
||||||
|
const q = Buffer.from(v);
|
||||||
|
q[8] &= 0x7f;
|
||||||
|
q[12] &= 0x7f;
|
||||||
|
const c = crypto.createCipheriv(`aes-${key.length * 8}-ctr`, key, q);
|
||||||
|
return Buffer.concat([c.update(data), c.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// key: 32, 48 or 64 bytes (two AES keys). Returns tag || ciphertext.
|
||||||
|
export function sivEncrypt(key, ads, plain) {
|
||||||
|
const half = key.length / 2;
|
||||||
|
const v = s2v(key.subarray(0, half), ads, plain);
|
||||||
|
return Buffer.concat([v, sivCtr(key.subarray(half), v, plain)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the plaintext, or null when the tag does not match.
|
||||||
|
export function sivDecrypt(key, ads, data) {
|
||||||
|
if (data.length < 16) return null;
|
||||||
|
const half = key.length / 2;
|
||||||
|
const v = data.subarray(0, 16);
|
||||||
|
const plain = sivCtr(key.subarray(half), v, data.subarray(16));
|
||||||
|
return crypto.timingSafeEqual(s2v(key.subarray(0, half), ads, plain), v) ? plain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keys ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function driveKeys(master, bucket) {
|
||||||
|
if (!Buffer.isBuffer(master) || master.length !== 32) throw new Error('the encryption key must be 32 bytes');
|
||||||
|
const okm = Buffer.from(crypto.hkdfSync('sha256', master, Buffer.alloc(0), `pithos/drive/v1/${bucket}`, 96));
|
||||||
|
return { bucket, name: okm.subarray(0, 64), body: okm.subarray(64) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- names -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const b64u = (b) => b.toString('base64url');
|
||||||
|
|
||||||
|
function segmentAds(dk, parent) {
|
||||||
|
return [Buffer.from(dk.bucket, 'utf8'), Buffer.from(parent, 'utf8')];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSegment(dk, parent, seg) {
|
||||||
|
return '~' + b64u(sivEncrypt(dk.name, segmentAds(dk, parent), Buffer.from(seg, 'utf8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// null when the segment is not one of ours (a plain name).
|
||||||
|
export function decryptSegment(dk, parent, seg) {
|
||||||
|
if (!seg.startsWith('~') || seg.length < 23) return null;
|
||||||
|
let raw;
|
||||||
|
try { raw = Buffer.from(seg.slice(1), 'base64url'); } catch { return null; }
|
||||||
|
if (b64u(raw) !== seg.slice(1)) return null;
|
||||||
|
const p = sivDecrypt(dk.name, segmentAds(dk, parent), raw);
|
||||||
|
return p ? p.toString('utf8') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "photos/2026/a.jpg" -> "~x/~y/~z"; a trailing "/" (folder marker or prefix)
|
||||||
|
// is kept. Empty segments are refused: S3 allows them but they make paths
|
||||||
|
// ambiguous.
|
||||||
|
export function encryptPath(dk, plain) {
|
||||||
|
if (!plain) return '';
|
||||||
|
const folder = plain.endsWith('/');
|
||||||
|
const segs = (folder ? plain.slice(0, -1) : plain).split('/');
|
||||||
|
const out = [];
|
||||||
|
let parent = '';
|
||||||
|
for (const s of segs) {
|
||||||
|
if (!s) throw new Error('empty folder names are not supported in encrypted drives');
|
||||||
|
out.push(encryptSegment(dk, parent, s));
|
||||||
|
parent += s + '/';
|
||||||
|
}
|
||||||
|
const key = out.join('/') + (folder ? '/' : '');
|
||||||
|
if (Buffer.byteLength(key) > 1024) throw new Error('this name is too long once encrypted (S3 allows 1024 bytes)');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { path, encrypted }. encrypted is true only if every segment was ours;
|
||||||
|
// a mix (a plain folder inside an encrypted one) reports false.
|
||||||
|
export function decryptPath(dk, key) {
|
||||||
|
if (!key) return { path: '', encrypted: false };
|
||||||
|
const folder = key.endsWith('/');
|
||||||
|
const segs = (folder ? key.slice(0, -1) : key).split('/');
|
||||||
|
const out = [];
|
||||||
|
let parent = '';
|
||||||
|
let all = true;
|
||||||
|
for (const s of segs) {
|
||||||
|
const d = decryptSegment(dk, parent, s);
|
||||||
|
if (d == null) all = false;
|
||||||
|
const plain = d ?? s;
|
||||||
|
out.push(plain);
|
||||||
|
parent += plain + '/';
|
||||||
|
}
|
||||||
|
return { path: out.join('/') + (folder ? '/' : ''), encrypted: all };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bodies ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function chunkSize(log2 = LOG2_CHUNK) { return 2 ** log2; }
|
||||||
|
|
||||||
|
export function cipherSize(plainSize, cs = chunkSize()) {
|
||||||
|
const chunks = Math.max(1, Math.ceil(plainSize / cs));
|
||||||
|
return HEADER_LEN + plainSize + chunks * TAG_LEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function plainSize(cipherSizeBytes, cs = chunkSize()) {
|
||||||
|
const c = cipherSizeBytes - HEADER_LEN;
|
||||||
|
if (c < TAG_LEN) return null;
|
||||||
|
const chunks = Math.ceil(c / (cs + TAG_LEN));
|
||||||
|
const p = c - chunks * TAG_LEN;
|
||||||
|
return p >= 0 && cipherSize(p, cs) === cipherSizeBytes ? p : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEncryptedHeader(buf) {
|
||||||
|
return buf.length >= HEADER_LEN && buf.subarray(0, 4).equals(MAGIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeader(header) {
|
||||||
|
if (!isEncryptedHeader(header)) throw new Error('not an encrypted Pithos file');
|
||||||
|
const log2 = header[20];
|
||||||
|
if (log2 < 12 || log2 > 24) throw new Error('unsupported chunk size in an encrypted file');
|
||||||
|
return { salt: header.subarray(4, 20), cs: 2 ** log2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileKey(dk, salt) {
|
||||||
|
return Buffer.from(crypto.hkdfSync('sha256', dk.body, salt, 'pithos/body/v1', 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonce(i, last) {
|
||||||
|
const n = Buffer.alloc(12);
|
||||||
|
n.writeBigUInt64BE(BigInt(i));
|
||||||
|
n[8] = last ? 1 : 0;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aad(header, dk, plainPath) {
|
||||||
|
return Buffer.concat([header, Buffer.from(dk.bucket + '\0' + plainPath, 'utf8')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newHeader() {
|
||||||
|
const h = Buffer.alloc(HEADER_LEN);
|
||||||
|
MAGIC.copy(h);
|
||||||
|
crypto.randomBytes(16).copy(h, 4);
|
||||||
|
h[20] = LOG2_CHUNK;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-chunks an async iterable of buffers into exact `size`-byte pieces.
|
||||||
|
async function* rechunk(source, size) {
|
||||||
|
let parts = [];
|
||||||
|
let have = 0;
|
||||||
|
for await (const b of source) {
|
||||||
|
let buf = Buffer.isBuffer(b) ? b : Buffer.from(b);
|
||||||
|
while (buf.length) {
|
||||||
|
const take = Math.min(size - have, buf.length);
|
||||||
|
parts.push(buf.subarray(0, take));
|
||||||
|
have += take;
|
||||||
|
buf = buf.subarray(take);
|
||||||
|
if (have === size) { yield Buffer.concat(parts); parts = []; have = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (have) yield Buffer.concat(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypts a stream whose length is known (the upload's Content-Length).
|
||||||
|
// Returns { stream, size } so the ciphertext length can be sent up front.
|
||||||
|
export function encryptBody(dk, plainPath, source, plainLen) {
|
||||||
|
const header = newHeader();
|
||||||
|
const { salt, cs } = parseHeader(header);
|
||||||
|
const key = fileKey(dk, salt);
|
||||||
|
const ad = aad(header, dk, plainPath);
|
||||||
|
const chunks = Math.max(1, Math.ceil(plainLen / cs));
|
||||||
|
const src = Buffer.isBuffer(source) ? [source] : source;
|
||||||
|
async function* gen() {
|
||||||
|
yield header;
|
||||||
|
let i = 0;
|
||||||
|
let seen = 0;
|
||||||
|
for await (const piece of rechunk(src, cs)) {
|
||||||
|
seen += piece.length;
|
||||||
|
if (i >= chunks || seen > plainLen) throw new Error('the upload is longer than its Content-Length');
|
||||||
|
yield sealChunk(key, ad, i, i === chunks - 1, piece);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (seen !== plainLen) throw new Error('the upload ended before its Content-Length');
|
||||||
|
if (plainLen === 0) yield sealChunk(key, ad, 0, true, Buffer.alloc(0));
|
||||||
|
}
|
||||||
|
return { stream: Readable.from(gen()), size: cipherSize(plainLen, cs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sealChunk(key, ad, i, last, piece) {
|
||||||
|
const c = crypto.createCipheriv('aes-256-gcm', key, nonce(i, last));
|
||||||
|
c.setAAD(ad);
|
||||||
|
return Buffer.concat([c.update(piece), c.final(), c.getAuthTag()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChunk(key, ad, i, last, sealed) {
|
||||||
|
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce(i, last));
|
||||||
|
d.setAAD(ad);
|
||||||
|
d.setAuthTag(sealed.subarray(sealed.length - TAG_LEN));
|
||||||
|
try {
|
||||||
|
return Buffer.concat([d.update(sealed.subarray(0, sealed.length - TAG_LEN)), d.final()]);
|
||||||
|
} catch {
|
||||||
|
throw new Error('this file was changed on the server or belongs to another drive or key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What to fetch for a plaintext byte range [start, end] (inclusive) of a file
|
||||||
|
// whose ciphertext is `cipherLen` bytes. Without a range, everything.
|
||||||
|
export function cipherRange(cipherLen, range, cs = chunkSize()) {
|
||||||
|
const plain = plainSize(cipherLen, cs);
|
||||||
|
if (plain == null) throw new Error('not an encrypted Pithos file (size does not match)');
|
||||||
|
const chunks = Math.max(1, Math.ceil(plain / cs));
|
||||||
|
let start = 0;
|
||||||
|
let end = plain - 1;
|
||||||
|
if (range) {
|
||||||
|
start = range.start;
|
||||||
|
end = Math.min(range.end ?? plain - 1, plain - 1);
|
||||||
|
if (start > end || start >= plain) return { plain, unsatisfiable: true };
|
||||||
|
}
|
||||||
|
const first = plain ? Math.floor(start / cs) : 0;
|
||||||
|
const lastChunk = plain ? Math.floor(end / cs) : 0;
|
||||||
|
return {
|
||||||
|
plain, start, end, chunks, first, lastChunk,
|
||||||
|
from: HEADER_LEN + first * (cs + TAG_LEN),
|
||||||
|
to: Math.min(cipherLen - 1, HEADER_LEN + (lastChunk + 1) * (cs + TAG_LEN) - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypts chunks first..lastChunk from `source` (the ciphertext bytes
|
||||||
|
// r.from..r.to) and yields only plaintext bytes r.start..r.end.
|
||||||
|
export function decryptBody(dk, plainPath, header, source, r) {
|
||||||
|
const { salt, cs } = parseHeader(header);
|
||||||
|
const key = fileKey(dk, salt);
|
||||||
|
const ad = aad(header, dk, plainPath);
|
||||||
|
async function* gen() {
|
||||||
|
let i = r.first;
|
||||||
|
for await (const sealed of rechunk(source, cs + TAG_LEN)) {
|
||||||
|
if (i > r.lastChunk) break;
|
||||||
|
const plain = openChunk(key, ad, i, i === r.chunks - 1, sealed);
|
||||||
|
const base = i * cs;
|
||||||
|
const a = Math.max(0, r.start - base);
|
||||||
|
const b = Math.min(plain.length, r.end - base + 1);
|
||||||
|
if (b > a) yield plain.subarray(a, b);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i <= r.lastChunk && r.plain > 0) throw new Error('the encrypted file ended early');
|
||||||
|
}
|
||||||
|
return Readable.from(gen());
|
||||||
|
}
|
||||||
|
|
||||||
|
// "bytes=a-b" | "bytes=a-" | "bytes=-n" -> { start, end } against a known size.
|
||||||
|
export function parseRange(header, size) {
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header || '').trim());
|
||||||
|
if (!m || (m[1] === '' && m[2] === '')) return null;
|
||||||
|
if (m[1] === '') {
|
||||||
|
const n = Number(m[2]);
|
||||||
|
return { start: Math.max(0, size - n), end: size - 1 };
|
||||||
|
}
|
||||||
|
return { start: Number(m[1]), end: m[2] === '' ? size - 1 : Number(m[2]) };
|
||||||
|
}
|
||||||
460
bundled-addons/pithos/core/hosted-routes.js
Normal file
460
bundled-addons/pithos/core/hosted-routes.js
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
// "On Silent Mode" inside the control server. In hosted mode the UI talks to
|
||||||
|
// this server exactly as before; this module answers by:
|
||||||
|
// - forwarding daemon, users, keys, stats and status calls to the gateway,
|
||||||
|
// - giving the object routes an S3 client that encrypts and decrypts here,
|
||||||
|
// - turning "Access" and "Share link" into gateway rules (and converting the
|
||||||
|
// files they cover to readable copies, since visitors have no key),
|
||||||
|
// - moving the s3d data folder to the server and back.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import * as admin from './admin.js';
|
||||||
|
import * as accounts from './accounts.js';
|
||||||
|
import { readPrefs, writePrefs } from './prefs.js';
|
||||||
|
import { ensureConfig } from './config.js';
|
||||||
|
import {
|
||||||
|
fileSecrets, signIn, gateway, HostedError, EncryptingS3Client, plainCovered,
|
||||||
|
dataFiles, uploadData, downloadData,
|
||||||
|
} from './hosted.js';
|
||||||
|
|
||||||
|
export const DEFAULT_HOSTED_URL = 'https://s3.silentmode.st';
|
||||||
|
|
||||||
|
// Calls forwarded as they are. The gateway allows the same set.
|
||||||
|
const FORWARD = [
|
||||||
|
['GET', /^\/api\/status$/], ['GET', /^\/api\/logs$/], ['GET', /^\/api\/stats$/],
|
||||||
|
['POST', /^\/api\/flush$/], ['GET', /^\/api\/autoflush$/],
|
||||||
|
['POST', /^\/api\/daemon\/(start|restart)$/],
|
||||||
|
['GET', /^\/api\/users$/], ['POST', /^\/api\/users$/], ['DELETE', /^\/api\/users\/[^/]+$/],
|
||||||
|
['GET', /^\/api\/keys$/], ['POST', /^\/api\/keys$/], ['DELETE', /^\/api\/keys\/[^/]+$/],
|
||||||
|
['GET', /^\/api\/account$/], ['PUT', /^\/api\/account\/label$/],
|
||||||
|
];
|
||||||
|
// Things that act on this computer's s3d and make no sense while drives live
|
||||||
|
// on the server.
|
||||||
|
const LOCAL_ONLY = [
|
||||||
|
[/^\/api\/binary\/install$/, 'There is nothing to install while your drives are on Silent Mode.'],
|
||||||
|
[/^\/api\/login/, 'Your drives are on Silent Mode, already connected to your Sia account.'],
|
||||||
|
[/^\/api\/account\/(switch|restore)$/, 'Move your drives back to this computer before switching Sia accounts.'],
|
||||||
|
[/^\/api\/daemon\/stop$/, 'On Silent Mode, s3d stops by itself when nobody uses it.'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function installHosted(ctx) {
|
||||||
|
const { route, configFile, cfg, daemon, broadcast, registration, resetRegistration, HttpError } = ctx;
|
||||||
|
const secrets = ctx.secrets || fileSecrets(configFile);
|
||||||
|
const prefs = () => readPrefs(configFile);
|
||||||
|
const hp = () => prefs().hosted || null;
|
||||||
|
const isHosted = () => prefs().mode === 'hosted' && !!hp()?.token;
|
||||||
|
const gw = () => {
|
||||||
|
const h = hp();
|
||||||
|
if (!h?.token) throw new HttpError(401, 'sign in to Silent Mode first');
|
||||||
|
return gateway(h.url, h.token);
|
||||||
|
};
|
||||||
|
const wrap = async (fn) => {
|
||||||
|
try { return await fn(); } catch (e) {
|
||||||
|
if (e instanceof HostedError) {
|
||||||
|
if (e.status === 401) { writePrefs(configFile, { hosted: { ...hp(), token: null } }); }
|
||||||
|
throw new HttpError(e.status, e.message);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- rules, cached briefly ----
|
||||||
|
let rulesCache = null;
|
||||||
|
async function rules() {
|
||||||
|
if (!isHosted()) return [];
|
||||||
|
if (rulesCache && Date.now() - rulesCache.at < 30_000) return rulesCache.list;
|
||||||
|
const list = await wrap(() => gw().call('GET', '/hosted/v1/rules'));
|
||||||
|
rulesCache = { at: Date.now(), list };
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const dropRules = () => { rulesCache = null; };
|
||||||
|
|
||||||
|
// ---- the S3 client for object routes ----
|
||||||
|
const keyCache = new Map();
|
||||||
|
async function hostedKeys(user) {
|
||||||
|
const c = keyCache.get(user);
|
||||||
|
if (c && Date.now() - c.at < 60_000) return c.keys;
|
||||||
|
const keys = await wrap(() => gw().call('GET', `/api/keys?user=${encodeURIComponent(user)}`));
|
||||||
|
keyCache.set(user, { at: Date.now(), keys });
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function s3For(user, localKeys) {
|
||||||
|
if (isHosted()) {
|
||||||
|
const keys = await hostedKeys(user);
|
||||||
|
if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`);
|
||||||
|
return new EncryptingS3Client({
|
||||||
|
endpoint: hp().url, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey,
|
||||||
|
master: () => secrets.encryptionKey(), rules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Back on this computer, files encrypted while hosted still decrypt.
|
||||||
|
if (prefs().encryptedData && localKeys?.length) {
|
||||||
|
return new EncryptingS3Client({
|
||||||
|
endpoint: `http://${cfg().apiAddress}`, accessKeyId: localKeys[0].accessKeyId, secretKey: localKeys[0].secretKey,
|
||||||
|
master: () => secrets.encryptionKey(), encryptWrites: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- status, logs and events from the server ----
|
||||||
|
let remote = null; // last /api/status from the gateway
|
||||||
|
async function hostedStatus(local) {
|
||||||
|
const h = hp();
|
||||||
|
try {
|
||||||
|
remote = await wrap(() => gw().call('GET', '/api/status'));
|
||||||
|
return { ...remote, host: local.host, mode: 'hosted', login: local.login, hosted: { ...remote.hosted, url: h.url } };
|
||||||
|
} catch (e) {
|
||||||
|
return { ...local, mode: 'hosted', daemon: { state: 'stopped', error: e.message }, hosted: { url: h.url, error: e.message }, registration: { registered: true } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lastSeq = 0;
|
||||||
|
let lastDaemon = '';
|
||||||
|
async function pollRemote() {
|
||||||
|
if (!isHosted() || !ctx.hasSubscribers()) return;
|
||||||
|
try {
|
||||||
|
const st = await gw().call('GET', '/api/status');
|
||||||
|
remote = st;
|
||||||
|
const d = JSON.stringify(st.daemon);
|
||||||
|
if (d !== lastDaemon) { lastDaemon = d; broadcast('status', st.daemon); }
|
||||||
|
const logs = await gw().call('GET', `/api/logs?since=${lastSeq}`);
|
||||||
|
for (const l of logs || []) { lastSeq = Math.max(lastSeq, l.seq); broadcast('log', l); }
|
||||||
|
} catch { /* the next poll tries again */ }
|
||||||
|
}
|
||||||
|
const poller = setInterval(() => { pollRemote(); }, 5000);
|
||||||
|
poller.unref?.();
|
||||||
|
|
||||||
|
// ---- intercept: runs before the local routes in hosted mode ----
|
||||||
|
async function intercept(req, res, url, send) {
|
||||||
|
if (!isHosted()) return false;
|
||||||
|
const p = url.pathname;
|
||||||
|
for (const [re, msg] of LOCAL_ONLY) if (re.test(p)) throw new HttpError(409, msg);
|
||||||
|
if (p === '/api/config' && req.method === 'PUT') throw new HttpError(409, 'Server settings are managed by Silent Mode while your drives are there.');
|
||||||
|
if (p === '/api/status') return false; // answered by the local route, which asks us
|
||||||
|
if (!FORWARD.some(([m, re]) => m === req.method && re.test(p))) return false;
|
||||||
|
const body = req.method === 'GET' ? undefined : await readJson(req);
|
||||||
|
const out = await wrap(() => gw().call(req.method, p + url.search, { body }));
|
||||||
|
if (/^\/api\/(users|keys)/.test(p) && req.method !== 'GET') keyCache.clear();
|
||||||
|
send(out);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- routes ----
|
||||||
|
route('GET', '/api/hosted', async () => {
|
||||||
|
const h = hp();
|
||||||
|
const out = { mode: isHosted() ? 'hosted' : 'local', url: h?.url || DEFAULT_HOSTED_URL, id: h?.id || null, signedIn: !!h?.token, secrets: secrets.kind || 'vault' };
|
||||||
|
if (h?.token) {
|
||||||
|
try { const warn = await settleMoves(); if (warn) out.warning = warn; } catch {}
|
||||||
|
out.mode = isHosted() ? 'hosted' : 'local';
|
||||||
|
try { out.me = await gw().call('GET', '/hosted/v1/me'); } catch (e) { out.error = e.message; if (e.status === 401) out.signedIn = false; }
|
||||||
|
}
|
||||||
|
if (!out.signedIn) {
|
||||||
|
try { out.info = await gateway(out.url).call('GET', '/hosted/v1/info'); } catch (e) { out.error = `Silent Mode is not reachable: ${e.message}`; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
route('POST', '/api/hosted/signin', async (req) => {
|
||||||
|
const { url = DEFAULT_HOSTED_URL, signup } = await readJson(req);
|
||||||
|
if (!/^https:\/\/[^\s/]+$|^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(url.replace(/\/$/, ''))) throw new HttpError(400, 'the server address must be https');
|
||||||
|
const r = await wrap(() => signIn(url.replace(/\/$/, ''), secrets, signup));
|
||||||
|
if (r.needsSignup) return r;
|
||||||
|
writePrefs(configFile, { hosted: { url: url.replace(/\/$/, ''), id: r.id, token: r.token } });
|
||||||
|
return { ok: true, id: r.id, state: r.state };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('POST', '/api/hosted/signout', async () => {
|
||||||
|
const h = hp();
|
||||||
|
if (h?.token) await gw().call('DELETE', '/hosted/v1/session').catch(() => {});
|
||||||
|
writePrefs(configFile, { hosted: h ? { ...h, token: null } : null, mode: 'local' });
|
||||||
|
dropRules();
|
||||||
|
keyCache.clear();
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use drives already on Silent Mode from this computer (a second device).
|
||||||
|
route('POST', '/api/hosted/mode', async (req) => {
|
||||||
|
const { mode } = await readJson(req);
|
||||||
|
if (mode === 'local') { writePrefs(configFile, { mode: 'local' }); return { mode: 'local' }; }
|
||||||
|
if (mode !== 'hosted') throw new HttpError(400, 'mode is local or hosted');
|
||||||
|
const me = await wrap(() => gw().call('GET', '/hosted/v1/me'));
|
||||||
|
if (me.state !== 'active') throw new HttpError(409, 'Silent Mode has no drives for you yet. Move them there first.');
|
||||||
|
writePrefs(configFile, { mode: 'hosted', encryptedData: true });
|
||||||
|
return { mode: 'hosted' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asks the server who holds the drives, a few times. null if it cannot tell.
|
||||||
|
async function serverState(g) {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try { return (await g.call('GET', '/hosted/v1/me')).state; } catch { await new Promise((r) => setTimeout(r, 1000 * (i + 1))); }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server has the drives: set the local copy aside so it can never run
|
||||||
|
// next to the server's (that would fork the account).
|
||||||
|
function adoptHosted(reg, users) {
|
||||||
|
const c = cfg();
|
||||||
|
if (fs.existsSync(path.join(c.directory, 's3d.db'))) {
|
||||||
|
accounts.archiveCurrent(c.directory, { indexerUrl: reg?.indexerUrl || null, users: users || [], movedTo: hp().url });
|
||||||
|
}
|
||||||
|
resetRegistration();
|
||||||
|
writePrefs(configFile, { mode: 'hosted', encryptedData: true, autostart: false, moveUncertain: null });
|
||||||
|
keyCache.clear();
|
||||||
|
dropRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finishes a move whose last answer was lost (see move-up and move-down).
|
||||||
|
async function settleMoves() {
|
||||||
|
const p = prefs();
|
||||||
|
if (!p.moveUncertain && !p.pendingRelease) return null;
|
||||||
|
const g = gw();
|
||||||
|
const state = await serverState(g);
|
||||||
|
if (state == null) return 'Silent Mode is not reachable, so an earlier move is not finished yet.';
|
||||||
|
if (p.moveUncertain) {
|
||||||
|
if (state === 'active') adoptHosted(p.moveUncertain.reg, p.moveUncertain.users);
|
||||||
|
else writePrefs(configFile, { moveUncertain: null }); // it never arrived; the local copy is the account
|
||||||
|
}
|
||||||
|
if (p.pendingRelease) {
|
||||||
|
if (state === 'moving-out') await g.call('POST', '/hosted/v1/data/release').catch(() => {});
|
||||||
|
if ((await serverState(g)) !== 'moving-out') writePrefs(configFile, { pendingRelease: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local -> Silent Mode. Nothing is re-uploaded to Sia: s3d's database and
|
||||||
|
// any objects still waiting for Sia move, then s3d runs on the server.
|
||||||
|
route('POST', '/api/hosted/move-up', async (req) => {
|
||||||
|
const { force } = await readJson(req);
|
||||||
|
const g = gw();
|
||||||
|
const progress = (p) => broadcast('move', { direction: 'up', ...p });
|
||||||
|
if (prefs().moveUncertain) throw new HttpError(409, 'An earlier move is not settled yet; open Settings again in a minute.');
|
||||||
|
const me = await wrap(() => g.call('GET', '/hosted/v1/me'));
|
||||||
|
if (me.state !== 'empty') throw new HttpError(409, 'Silent Mode already holds drives for you. Use them from here, or move them back first.');
|
||||||
|
const c = cfg();
|
||||||
|
const reg = await registration();
|
||||||
|
if (!reg?.registered) throw new HttpError(409, 'Connect this computer to your Sia account first; then move the connection to Silent Mode.');
|
||||||
|
const wasRunning = !!daemon.child;
|
||||||
|
if (wasRunning) {
|
||||||
|
progress({ phase: 'flush' });
|
||||||
|
try { await admin.flush(c); } catch (e) {
|
||||||
|
if (!force) throw new HttpError(409, `Some uploads could not reach Sia yet (${e.message}). Try again later, or move anyway: they will finish on the server.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress({ phase: 'stop' });
|
||||||
|
await daemon.stop();
|
||||||
|
const restartLocal = () => { if (wasRunning) { try { ensureConfig(configFile); daemon.start(); } catch {} } };
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = await dataFiles(c.directory);
|
||||||
|
if (!files.some((f) => f.path === 's3d.db')) throw new HttpError(409, 'there is no s3d database to move');
|
||||||
|
await uploadData(g, c.directory, files, progress);
|
||||||
|
} catch (e) {
|
||||||
|
// Nothing was committed: the server discards what arrived, s3d runs here again.
|
||||||
|
await g.call('POST', '/hosted/v1/data/abort').catch(() => {});
|
||||||
|
restartLocal();
|
||||||
|
progress({ phase: 'failed', error: e.message });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
progress({ phase: 'start' });
|
||||||
|
let r = null;
|
||||||
|
let commitError = null;
|
||||||
|
try { r = await wrap(() => g.call('POST', '/hosted/v1/data/commit', { body: { files } })); } catch (e) { commitError = e; }
|
||||||
|
// From here an answer can be lost in either direction, so the server's
|
||||||
|
// state decides which copy is the account.
|
||||||
|
const state = r ? 'active' : await serverState(g);
|
||||||
|
if (state === 'active') {
|
||||||
|
adoptHosted(reg, r?.users);
|
||||||
|
progress({ phase: 'done' });
|
||||||
|
return { ok: true, registered: true, users: r?.users || [] };
|
||||||
|
}
|
||||||
|
if (state === 'empty') {
|
||||||
|
restartLocal();
|
||||||
|
progress({ phase: 'failed', error: commitError?.message });
|
||||||
|
throw commitError || new HttpError(502, 'the server did not take the drives');
|
||||||
|
}
|
||||||
|
// Unknown: keep s3d stopped here until the server can be asked again.
|
||||||
|
writePrefs(configFile, { moveUncertain: { at: Date.now(), reg: { indexerUrl: reg.indexerUrl }, users: [] } });
|
||||||
|
progress({ phase: 'failed', error: 'no answer from Silent Mode' });
|
||||||
|
throw new HttpError(502, 'Silent Mode stopped answering during the move. s3d stays stopped here until Pithos can check which side has your drives; open Settings again in a minute.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Silent Mode -> local. The server flushes and stops its s3d, the folder
|
||||||
|
// comes down, the local s3d starts on it, and only then is the server copy
|
||||||
|
// released. Once release is sent the local copy is never removed.
|
||||||
|
route('POST', '/api/hosted/move-down', async (req) => {
|
||||||
|
const { force } = await readJson(req);
|
||||||
|
const g = gw();
|
||||||
|
const progress = (p) => broadcast('move', { direction: 'down', ...p });
|
||||||
|
const c = cfg();
|
||||||
|
if (daemon.child) await daemon.stop();
|
||||||
|
let archived = null;
|
||||||
|
if (fs.existsSync(path.join(c.directory, 's3d.db'))) {
|
||||||
|
archived = accounts.archiveCurrent(c.directory, { note: 'set aside to bring drives back from Silent Mode' }).name;
|
||||||
|
}
|
||||||
|
progress({ phase: 'flush' });
|
||||||
|
let man;
|
||||||
|
try { man = await wrap(() => g.call('POST', `/hosted/v1/data/checkout${force ? '?force=1' : ''}`)); } catch (e) {
|
||||||
|
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const written = [];
|
||||||
|
try {
|
||||||
|
await downloadData(g, c.directory, man.files, progress, written);
|
||||||
|
progress({ phase: 'start' });
|
||||||
|
ensureConfig(configFile);
|
||||||
|
resetRegistration();
|
||||||
|
daemon.start();
|
||||||
|
const until = Date.now() + 30_000;
|
||||||
|
while (daemon.state === 'starting' && Date.now() < until) await new Promise((r) => setTimeout(r, 200));
|
||||||
|
if (daemon.state !== 'running') throw new Error('s3d did not start on the moved folder');
|
||||||
|
const reg = await registration();
|
||||||
|
if (!reg?.registered) throw new Error('the moved folder has no Sia connection');
|
||||||
|
} catch (e) {
|
||||||
|
// Only what this move wrote is removed; the server keeps its copy.
|
||||||
|
await daemon.stop().catch(() => {});
|
||||||
|
for (const f of written) fs.rmSync(f, { force: true });
|
||||||
|
if (archived) { try { accounts.restoreArchive(c.directory, archived, {}); } catch {} }
|
||||||
|
writePrefs(configFile, { mode: 'hosted' });
|
||||||
|
await g.call('POST', '/hosted/v1/data/cancel-checkout').catch(() => {});
|
||||||
|
progress({ phase: 'failed', error: e.message });
|
||||||
|
throw new HttpError(502, `Moving back failed and nothing changed: ${e.message}`);
|
||||||
|
}
|
||||||
|
// The drives now run here. Whatever happens to the release, this copy stays.
|
||||||
|
writePrefs(configFile, { mode: 'local', autostart: true, encryptedData: true, pendingRelease: true });
|
||||||
|
keyCache.clear();
|
||||||
|
dropRules();
|
||||||
|
try {
|
||||||
|
await g.call('POST', '/hosted/v1/data/release');
|
||||||
|
writePrefs(configFile, { pendingRelease: null });
|
||||||
|
} catch { /* settleMoves() retries; the server copy stays parked meanwhile */ }
|
||||||
|
progress({ phase: 'done' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rules: public folders, shared keys, links. Files they cover become
|
||||||
|
// readable copies; removing a rule encrypts them again.
|
||||||
|
route('GET', '/api/hosted/rules', async () => rules());
|
||||||
|
|
||||||
|
route('POST', '/api/hosted/rules', async (req) => {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const client = await s3For(body.user);
|
||||||
|
if (!client) throw new HttpError(409, 'not on Silent Mode');
|
||||||
|
const rule = await wrap(() => gw().call('POST', '/hosted/v1/rules', { body }));
|
||||||
|
dropRules();
|
||||||
|
await client.convert(rule.bucket, rule.prefix, { toPlain: true, onProgress: (p) => broadcast('convert', { rule: rule.id, ...p }) });
|
||||||
|
return rule;
|
||||||
|
});
|
||||||
|
|
||||||
|
route('DELETE', '/api/hosted/rules', async (req, url) => {
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
const user = url.searchParams.get('user');
|
||||||
|
const rule = (await rules()).find((r) => r.id === id);
|
||||||
|
if (!rule) throw new HttpError(404, 'no such rule');
|
||||||
|
await wrap(() => gw().call('DELETE', `/hosted/v1/rules?id=${encodeURIComponent(id)}`));
|
||||||
|
dropRules();
|
||||||
|
const remaining = await rules();
|
||||||
|
const client = await s3For(user);
|
||||||
|
if (client) {
|
||||||
|
await client.convert(rule.bucket, rule.prefix, {
|
||||||
|
toPlain: false,
|
||||||
|
skip: (key) => plainCovered(remaining, rule.bucket, key),
|
||||||
|
onProgress: (p) => broadcast('convert', { rule: rule.id, ...p }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Files from before hosting (or from another S3 client) are stored as they
|
||||||
|
// came. This encrypts every one that nothing shares.
|
||||||
|
route('POST', '/api/hosted/encrypt', async (req) => {
|
||||||
|
const { user, bucket } = await readJson(req);
|
||||||
|
const client = await s3For(user);
|
||||||
|
if (!isHosted() || !client) throw new HttpError(409, 'not on Silent Mode');
|
||||||
|
const current = await rules();
|
||||||
|
return client.convert(bucket, '', {
|
||||||
|
toPlain: false,
|
||||||
|
skip: (key) => plainCovered(current, bucket, key),
|
||||||
|
onProgress: (p) => broadcast('convert', p),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole-drive "Access" dialog, answered with a public rule on "".
|
||||||
|
async function drivePolicy(bucket) {
|
||||||
|
const r = (await rules()).find((x) => x.type === 'public' && x.bucket === bucket && x.prefix === '');
|
||||||
|
return { policy: null, public: r ? r.rights : 'private', hosted: true };
|
||||||
|
}
|
||||||
|
async function setDrivePolicy(user, bucket, mode) {
|
||||||
|
const current = (await rules()).filter((x) => x.type === 'public' && x.bucket === bucket && x.prefix === '');
|
||||||
|
const client = await s3For(user);
|
||||||
|
for (const r of current) await wrap(() => gw().call('DELETE', `/hosted/v1/rules?id=${encodeURIComponent(r.id)}`));
|
||||||
|
dropRules();
|
||||||
|
if (mode === 'private') {
|
||||||
|
const remaining = await rules();
|
||||||
|
await client.convert(bucket, '', { toPlain: false, skip: (key) => plainCovered(remaining, bucket, key), onProgress: (p) => broadcast('convert', p) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await wrap(() => gw().call('POST', '/hosted/v1/rules', { body: { type: 'public', bucket, prefix: '', rights: mode } }));
|
||||||
|
dropRules();
|
||||||
|
await client.convert(bucket, '', { toPlain: true, onProgress: (p) => broadcast('convert', p) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Share link" for one file: a link rule, and the file stored readable.
|
||||||
|
async function shareLink(user, bucket, key, seconds) {
|
||||||
|
const client = await s3For(user);
|
||||||
|
const expires = Date.now() + Math.min(Math.max(60, seconds | 0), 30 * 86400) * 1000;
|
||||||
|
const rule = await wrap(() => gw().call('POST', '/hosted/v1/rules', { body: { type: 'link', bucket, prefix: key, expires } }));
|
||||||
|
dropRules();
|
||||||
|
await client.convert(bucket, key, { toPlain: true, skip: (k) => k !== key });
|
||||||
|
const links = (prefs().hostedLinks || []).filter((l) => l.expires > Date.now());
|
||||||
|
links.push({ user, bucket, key, expires });
|
||||||
|
writePrefs(configFile, { hostedLinks: links });
|
||||||
|
return `${hp().url}/l/${rule.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files shared by a link that has run out go back to being encrypted.
|
||||||
|
async function sweepLinks() {
|
||||||
|
if (!isHosted()) return;
|
||||||
|
const all = prefs().hostedLinks || [];
|
||||||
|
const expired = all.filter((l) => l.expires <= Date.now());
|
||||||
|
if (!expired.length) return;
|
||||||
|
dropRules();
|
||||||
|
const remaining = await rules();
|
||||||
|
for (const l of expired) {
|
||||||
|
if (plainCovered(remaining, l.bucket, l.key)) continue;
|
||||||
|
const client = await s3For(l.user).catch(() => null);
|
||||||
|
await client?.convert(l.bucket, l.key, { toPlain: false, skip: (k) => k !== l.key }).catch(() => {});
|
||||||
|
}
|
||||||
|
writePrefs(configFile, { hostedLinks: all.filter((l) => l.expires > Date.now()) });
|
||||||
|
}
|
||||||
|
const sweeper = setInterval(() => { sweepLinks().catch(() => {}); }, 10 * 60_000);
|
||||||
|
sweeper.unref?.();
|
||||||
|
|
||||||
|
// Key file export/import for hosts without a vault.
|
||||||
|
route('GET', '/api/hosted/secret', () => {
|
||||||
|
if (!secrets.exportSecret) throw new HttpError(400, 'your keys come from the Theseus vault; back up the vault instead');
|
||||||
|
return secrets.exportSecret();
|
||||||
|
});
|
||||||
|
route('POST', '/api/hosted/secret', async (req) => {
|
||||||
|
if (!secrets.importSecret) throw new HttpError(400, 'your keys come from the Theseus vault');
|
||||||
|
if (isHosted() || hp()?.token) throw new HttpError(409, 'sign out of Silent Mode before replacing your key');
|
||||||
|
secrets.importSecret(await readJson(req));
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHosted, intercept, s3For, hostedStatus, drivePolicy, setDrivePolicy, shareLink,
|
||||||
|
userNames: async () => (await wrap(() => gw().call('GET', '/api/users'))).map((u) => u.name),
|
||||||
|
remoteDaemon: () => remote?.daemon || { state: 'stopped' },
|
||||||
|
close() { clearInterval(poller); clearInterval(sweeper); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req) {
|
||||||
|
const parts = [];
|
||||||
|
for await (const c of req) parts.push(c);
|
||||||
|
const text = Buffer.concat(parts).toString('utf8');
|
||||||
|
if (!text) return {};
|
||||||
|
try { return JSON.parse(text); } catch { return {}; }
|
||||||
|
}
|
||||||
437
bundled-addons/pithos/core/hosted.js
Normal file
437
bundled-addons/pithos/core/hosted.js
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
// The client half of "On Silent Mode": signing in to pithos-hosted, the S3
|
||||||
|
// client that encrypts on the way out and decrypts on the way in, converting
|
||||||
|
// folders between private (encrypted) and readable when they are shared, and
|
||||||
|
// moving the s3d data folder between this computer and the server.
|
||||||
|
//
|
||||||
|
// Nothing secret leaves this process: the identity key signs a nonce, the
|
||||||
|
// encryption key never goes anywhere, and the Sia recovery phrase is not
|
||||||
|
// involved at all (only the connected s3d data folder moves).
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { S3Client, readBody, objectPath } from './s3.js';
|
||||||
|
import {
|
||||||
|
driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, plainSize, parseRange, HEADER_LEN,
|
||||||
|
} from './crypt.js';
|
||||||
|
|
||||||
|
// ---- secrets ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
||||||
|
|
||||||
|
// Hosts with a vault (Theseus) pass their own; everyone else gets a random
|
||||||
|
// secret in a file beside s3d.yml. Losing that file loses the files it
|
||||||
|
// encrypted, so the UI offers to export it.
|
||||||
|
export function fileSecrets(configFile) {
|
||||||
|
const file = path.join(path.dirname(configFile), 'pithos-hosted-secret.json');
|
||||||
|
let root = null;
|
||||||
|
const NOTE = 'Pithos hosted identity and encryption key. Anyone with this file can read your encrypted files; without it they are lost.';
|
||||||
|
function save(secret) {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tmp = `${file}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify({ v: 1, secret: secret.toString('base64'), note: NOTE }, null, 2), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
// A key is only ever created when there is no file at all. A file that
|
||||||
|
// cannot be read or parsed is an error, never a reason to make a new key:
|
||||||
|
// that would silently orphan everything the old one encrypted.
|
||||||
|
function load() {
|
||||||
|
if (root) return root;
|
||||||
|
let text;
|
||||||
|
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
|
||||||
|
if (e.code !== 'ENOENT') throw new Error(`cannot read your Pithos key file (${file}): ${e.message}`);
|
||||||
|
root = crypto.randomBytes(32);
|
||||||
|
save(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
let b = null;
|
||||||
|
try { b = Buffer.from(JSON.parse(text).secret, 'base64'); } catch {}
|
||||||
|
if (!b || b.length !== 32) throw new Error(`your Pithos key file is damaged (${file}); restore it from your backup`);
|
||||||
|
root = b;
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
const derive = (label) => Buffer.from(crypto.hkdfSync('sha256', load(), Buffer.alloc(0), label, 32));
|
||||||
|
return {
|
||||||
|
kind: 'file',
|
||||||
|
file,
|
||||||
|
identitySeed: async () => derive('pithos/hosted-identity/v1'),
|
||||||
|
encryptionKey: async () => derive('pithos/hosted-encryption/v1'),
|
||||||
|
exportSecret: () => JSON.parse(fs.readFileSync(file, 'utf8')),
|
||||||
|
importSecret(obj) {
|
||||||
|
const b = Buffer.from(String(obj?.secret || ''), 'base64');
|
||||||
|
if (b.length !== 32) throw new Error('this is not a Pithos key file');
|
||||||
|
// The key being replaced may still be the only way to read some files.
|
||||||
|
if (fs.existsSync(file)) fs.copyFileSync(file, file.replace(/\.json$/, `.replaced-${Date.now()}.json`));
|
||||||
|
save(b);
|
||||||
|
root = b;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identityFromSeed(seed) {
|
||||||
|
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, seed]), format: 'der', type: 'pkcs8' });
|
||||||
|
const pub = crypto.createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(-32);
|
||||||
|
return { pubkey: pub.toString('hex'), sign: (msg) => crypto.sign(null, Buffer.from(msg, 'utf8'), key).toString('base64') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- gateway calls -------------------------------------------------------------
|
||||||
|
|
||||||
|
export class HostedError extends Error {
|
||||||
|
constructor(status, message, data) { super(message); this.status = status; this.data = data; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gateway(url, token) {
|
||||||
|
const base = url.replace(/\/$/, '');
|
||||||
|
async function call(method, p, { body, headers = {}, raw, stream } = {}) {
|
||||||
|
const h = { ...headers };
|
||||||
|
if (token) h.authorization = `Bearer ${token}`;
|
||||||
|
let payload = body;
|
||||||
|
if (body !== undefined && !stream && !Buffer.isBuffer(body)) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
|
||||||
|
const res = await fetch(base + p, { method, headers: h, body: payload, duplex: stream ? 'half' : undefined });
|
||||||
|
if (raw) return res;
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 300) }; }
|
||||||
|
if (!res.ok) throw new HostedError(res.status, data?.error || `HTTP ${res.status}`, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return { base, call, auth: token ? `Bearer ${token}` : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(url, secrets, signup) {
|
||||||
|
const g = gateway(url);
|
||||||
|
const id = identityFromSeed(await secrets.identitySeed());
|
||||||
|
const { nonce, message } = await g.call('GET', '/hosted/v1/challenge');
|
||||||
|
try {
|
||||||
|
return await g.call('POST', '/hosted/v1/session', { body: { pubkey: id.pubkey, nonce, signature: id.sign(message), signup } });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404 && e.data?.needsSignup) return { needsSignup: true, ...e.data };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- which files are readable ------------------------------------------------------
|
||||||
|
|
||||||
|
// Files anyone else can reach (public folders, shared keys, share links) must
|
||||||
|
// be stored readable; everything else is encrypted.
|
||||||
|
export function plainCovered(rules, bucket, key) {
|
||||||
|
const now = Date.now();
|
||||||
|
return rules.some((r) => r.bucket === bucket && (!r.expires || r.expires > now) && (
|
||||||
|
r.type === 'link' ? r.prefix === key : key.startsWith(r.prefix)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript',
|
||||||
|
json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip',
|
||||||
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon',
|
||||||
|
mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus',
|
||||||
|
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska',
|
||||||
|
};
|
||||||
|
export const guessType = (key) => MIME[String(key).split('.').pop().toLowerCase()] || 'application/octet-stream';
|
||||||
|
|
||||||
|
// ---- the encrypting S3 client ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Same surface as S3Client for everything the control server's object routes
|
||||||
|
// use. Keys and sizes it returns are the plain ones. `encryptWrites` is false
|
||||||
|
// on this computer (local s3d already keeps files private) but reads still
|
||||||
|
// decrypt, so drives brought back from Silent Mode stay readable.
|
||||||
|
export class EncryptingS3Client {
|
||||||
|
constructor({ endpoint, accessKeyId, secretKey, master, rules = async () => [], encryptWrites = true }) {
|
||||||
|
this.inner = new S3Client({ endpoint, accessKeyId, secretKey });
|
||||||
|
this.master = master; // async () => Buffer(32)
|
||||||
|
this.rules = rules;
|
||||||
|
this.encryptWrites = encryptWrites;
|
||||||
|
this.keys = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async dk(bucket) {
|
||||||
|
if (!this.keys.has(bucket)) this.keys.set(bucket, driveKeys(await this.master(), bucket));
|
||||||
|
return this.keys.get(bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeAsPlain(bucket, key) {
|
||||||
|
return !this.encryptWrites || plainCovered(await this.rules(), bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
listBuckets() { return this.inner.listBuckets(); }
|
||||||
|
createBucket(b) { return this.inner.createBucket(b); }
|
||||||
|
deleteBucket(b) { return this.inner.deleteBucket(b); }
|
||||||
|
presign(...a) { return this.inner.presign(...a); }
|
||||||
|
getPolicy(b) { return this.inner.getPolicy(b); }
|
||||||
|
putPolicy(b, p) { return this.inner.putPolicy(b, p); }
|
||||||
|
deletePolicy(b) { return this.inner.deletePolicy(b); }
|
||||||
|
|
||||||
|
// A folder can hold both forms (files from before hosting are plain), so a
|
||||||
|
// listing below the root asks for both prefixes and merges them.
|
||||||
|
async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) {
|
||||||
|
const dk = await this.dk(bucket);
|
||||||
|
const cut = prefix.lastIndexOf('/') + 1;
|
||||||
|
const folder = prefix.slice(0, cut);
|
||||||
|
const encFolder = folder ? encryptPath(dk, folder) : '';
|
||||||
|
const state = token ? JSON.parse(Buffer.from(token, 'base64url').toString('utf8')) : {};
|
||||||
|
const sources = folder ? [['e', encFolder], ['p', folder]] : [['p', '']];
|
||||||
|
const folders = new Map();
|
||||||
|
const objects = new Map();
|
||||||
|
const next = {};
|
||||||
|
for (const [name, pfx] of sources) {
|
||||||
|
// A source that has run out stays out on later pages.
|
||||||
|
if (state[name] === null) { next[name] = null; continue; }
|
||||||
|
const page = await this.inner.listObjects(bucket, { prefix: pfx, delimiter, token: state[name] || undefined, max });
|
||||||
|
next[name] = page.truncated ? page.nextToken : null;
|
||||||
|
for (const f of page.folders) {
|
||||||
|
const d = decryptPath(dk, f);
|
||||||
|
if (d.path.startsWith(prefix)) folders.set(d.path, { path: d.path, encrypted: d.encrypted });
|
||||||
|
}
|
||||||
|
for (const o of page.objects) {
|
||||||
|
const d = decryptPath(dk, o.key);
|
||||||
|
if (!d.path.startsWith(prefix)) continue;
|
||||||
|
const size = d.encrypted && !d.path.endsWith('/') ? plainSize(o.size) : o.size;
|
||||||
|
// Same name in both forms (re-uploaded after hosting): the encrypted one is newer.
|
||||||
|
if (objects.has(d.path) && !d.encrypted) continue;
|
||||||
|
objects.set(d.path, { key: d.path, size: size ?? o.size, modified: o.modified, etag: o.etag, encrypted: d.encrypted, storageKey: o.key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const more = Object.values(next).some((v) => v);
|
||||||
|
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
return {
|
||||||
|
folders: [...folders.keys()].sort(order),
|
||||||
|
objects: [...objects.values()].sort((a, b) => order(a.key, b.key)),
|
||||||
|
truncated: more,
|
||||||
|
nextToken: more ? Buffer.from(JSON.stringify(next)).toString('base64url') : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async putObject(bucket, key, body, { contentType, contentLength } = {}) {
|
||||||
|
const dk = await this.dk(bucket);
|
||||||
|
if (await this.storeAsPlain(bucket, key)) {
|
||||||
|
const r = await this.inner.putObject(bucket, key, body, { contentType, contentLength });
|
||||||
|
// Reads prefer the encrypted form, so an older encrypted copy under the
|
||||||
|
// same name would hide this new version.
|
||||||
|
const ek = encryptPath(dk, key);
|
||||||
|
if (ek !== key) await this.inner.deleteObject(bucket, ek).catch(() => {});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
const ek = encryptPath(dk, key);
|
||||||
|
if (key.endsWith('/')) return this.inner.putObject(bucket, ek, Buffer.alloc(0), { contentLength: 0 });
|
||||||
|
const len = Number(contentLength ?? (Buffer.isBuffer(body) ? body.length : NaN));
|
||||||
|
if (!Number.isFinite(len)) throw new Error('encrypted uploads need a Content-Length');
|
||||||
|
const { stream, size } = encryptBody(dk, key, body, len);
|
||||||
|
const r = await this.inner.putObject(bucket, ek, stream, { contentType: 'application/octet-stream', contentLength: size });
|
||||||
|
// An older readable copy under the same name would shadow nothing but
|
||||||
|
// would still be readable on the server: remove it.
|
||||||
|
if (ek !== key) await this.inner.deleteObject(bucket, key).catch(() => {});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves to a readable stream carrying statusCode and headers, like the
|
||||||
|
// http.IncomingMessage S3Client.getObject returns.
|
||||||
|
async getObject(bucket, key, { range } = {}) {
|
||||||
|
const dk = await this.dk(bucket);
|
||||||
|
const ek = encryptPath(dk, key);
|
||||||
|
const head = await this.inner.request('HEAD', objectPath(bucket, ek));
|
||||||
|
head.resume();
|
||||||
|
if (head.statusCode === 404 || head.statusCode === 403) return this.inner.getObject(bucket, key, { range });
|
||||||
|
if (head.statusCode >= 300) return this.inner.getObject(bucket, ek, { range });
|
||||||
|
const total = Number(head.headers['content-length']);
|
||||||
|
const r = cipherRange(total, range ? parseRange(range, plainSize(total) ?? 0) : null);
|
||||||
|
const base = { 'content-type': guessType(key), 'accept-ranges': 'bytes' };
|
||||||
|
if (head.headers.etag) base.etag = head.headers.etag;
|
||||||
|
if (head.headers['last-modified']) base['last-modified'] = head.headers['last-modified'];
|
||||||
|
if (r.unsatisfiable) return withStatus(Readable.from([]), 416, { ...base, 'content-range': `bytes */${r.plain}` });
|
||||||
|
const headerRes = await this.inner.getObject(bucket, ek, { range: `bytes=0-${r.first === 0 ? r.to : HEADER_LEN - 1}` });
|
||||||
|
if (headerRes.statusCode >= 300) return headerRes;
|
||||||
|
// The header arrives first; when the wanted chunks follow it directly the
|
||||||
|
// same response carries them too.
|
||||||
|
const iter = headerRes[Symbol.asyncIterator]();
|
||||||
|
let first = Buffer.alloc(0);
|
||||||
|
while (first.length < HEADER_LEN) {
|
||||||
|
const { value, done } = await iter.next();
|
||||||
|
if (done) throw new Error('encrypted file is shorter than its header');
|
||||||
|
first = Buffer.concat([first, value]);
|
||||||
|
}
|
||||||
|
const header = first.subarray(0, HEADER_LEN);
|
||||||
|
let body;
|
||||||
|
if (r.first === 0) {
|
||||||
|
const rest = first.subarray(HEADER_LEN);
|
||||||
|
body = Readable.from((async function* () { if (rest.length) yield rest; for (;;) { const { value, done } = await iter.next(); if (done) return; yield value; } })());
|
||||||
|
} else {
|
||||||
|
iter.return?.();
|
||||||
|
headerRes.destroy();
|
||||||
|
body = await this.inner.getObject(bucket, ek, { range: `bytes=${r.from}-${r.to}` });
|
||||||
|
if (body.statusCode >= 300) return body;
|
||||||
|
}
|
||||||
|
const out = decryptBody(dk, key, header, r.plain ? body : Readable.from([]), r);
|
||||||
|
const headers = { ...base, 'content-length': String(r.plain ? r.end - r.start + 1 : 0) };
|
||||||
|
let status = 200;
|
||||||
|
if (range) { status = 206; headers['content-range'] = `bytes ${r.start}-${r.end}/${r.plain}`; }
|
||||||
|
return withStatus(out, status, headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteObject(bucket, key) {
|
||||||
|
const dk = await this.dk(bucket);
|
||||||
|
const ek = encryptPath(dk, key);
|
||||||
|
await this.inner.deleteObject(bucket, ek);
|
||||||
|
if (ek !== key) await this.inner.deleteObject(bucket, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.inner.deleteObject(bucket, o.storageKey); n++; }
|
||||||
|
token = page.truncated ? page.nextToken : null;
|
||||||
|
} while (token);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-stores every file under prefix in the wanted form: readable (shared)
|
||||||
|
// or encrypted (private). Rules must already say what the prefix should be.
|
||||||
|
async convert(bucket, prefix, { toPlain, onProgress = () => {}, skip = () => false }) {
|
||||||
|
const dk = await this.dk(bucket);
|
||||||
|
let done = 0;
|
||||||
|
let token;
|
||||||
|
const todo = [];
|
||||||
|
do {
|
||||||
|
const page = await this.listObjects(bucket, { prefix, delimiter: '', token });
|
||||||
|
for (const o of page.objects) if (o.encrypted === toPlain && !skip(o.key)) todo.push(o);
|
||||||
|
token = page.truncated ? page.nextToken : null;
|
||||||
|
} while (token);
|
||||||
|
for (const o of todo) {
|
||||||
|
onProgress({ done, total: todo.length, key: o.key });
|
||||||
|
const target = toPlain ? o.key : encryptPath(dk, o.key);
|
||||||
|
if (o.key.endsWith('/')) {
|
||||||
|
await this.inner.putObject(bucket, target, Buffer.alloc(0), { contentLength: 0 });
|
||||||
|
} else if (toPlain) {
|
||||||
|
const src = await this.getObject(bucket, o.key);
|
||||||
|
if (src.statusCode >= 300) throw new Error(`could not read ${o.key}: HTTP ${src.statusCode}`);
|
||||||
|
await this.inner.putObject(bucket, target, src, { contentType: guessType(o.key), contentLength: o.size });
|
||||||
|
} else {
|
||||||
|
const src = await this.inner.getObject(bucket, o.storageKey);
|
||||||
|
if (src.statusCode >= 300) throw new Error(`could not read ${o.key}: HTTP ${src.statusCode}`);
|
||||||
|
const { stream, size } = encryptBody(dk, o.key, src, o.size);
|
||||||
|
await this.inner.putObject(bucket, target, stream, { contentType: 'application/octet-stream', contentLength: size });
|
||||||
|
}
|
||||||
|
await this.inner.deleteObject(bucket, o.storageKey);
|
||||||
|
done++;
|
||||||
|
}
|
||||||
|
onProgress({ done, total: todo.length });
|
||||||
|
return { converted: done };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withStatus(stream, statusCode, headers) {
|
||||||
|
stream.statusCode = statusCode;
|
||||||
|
stream.headers = headers;
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- moving the data folder ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// What travels: the database and objects still waiting for Sia. Logs, temp
|
||||||
|
// files, the local config, the key file and earlier accounts stay on this
|
||||||
|
// computer, and a path naming any of them is refused in either direction.
|
||||||
|
const SKIP_TOP = new Set(['s3d.yml', 'pithos-prefs.json', 'pithos-hosted-secret.json', 'pithos-archive.json', 'pithos.json', 'tmp', 's3d.log']);
|
||||||
|
const skipTop = (name) => SKIP_TOP.has(name) || name.startsWith('previous-account-') || name.startsWith('pithos-hosted-secret') || name.endsWith('.log') || name.endsWith('.tmp');
|
||||||
|
|
||||||
|
// "uploads/ab.obj" style paths only: forward slashes, no empty, "." or ".."
|
||||||
|
// segments, no drive letters or backslashes, and nothing that is ours.
|
||||||
|
export function isTransferPath(rel) {
|
||||||
|
if (typeof rel !== 'string' || !rel || rel.length > 512) return false;
|
||||||
|
if (/[\\:\x00]/.test(rel) || rel.startsWith('/')) return false;
|
||||||
|
const segs = rel.split('/');
|
||||||
|
if (segs.some((x) => !x || x === '.' || x === '..')) return false;
|
||||||
|
return !skipTop(segs[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file a transfer path names, proven to sit inside dir.
|
||||||
|
export function transferTarget(dir, rel) {
|
||||||
|
if (!isTransferPath(rel)) throw new Error(`refusing an unsafe file path: ${String(rel).slice(0, 80)}`);
|
||||||
|
const base = path.resolve(dir);
|
||||||
|
const target = path.resolve(base, ...rel.split('/'));
|
||||||
|
const r = path.relative(base, target);
|
||||||
|
if (!r || r.startsWith('..') || path.isAbsolute(r)) throw new Error(`refusing an unsafe file path: ${rel}`);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const h = crypto.createHash('sha256');
|
||||||
|
let size = 0;
|
||||||
|
fs.createReadStream(file)
|
||||||
|
.on('data', (c) => { h.update(c); size += c.length; })
|
||||||
|
.on('error', reject)
|
||||||
|
.on('end', () => resolve({ size, sha256: h.digest('hex') }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashes stream from disk: buffered objects can be gigabytes.
|
||||||
|
export async function dataFiles(dataDir) {
|
||||||
|
const out = [];
|
||||||
|
const rec = async (rel) => {
|
||||||
|
for (const e of fs.readdirSync(path.join(dataDir, rel), { withFileTypes: true })) {
|
||||||
|
const r = rel ? `${rel}/${e.name}` : e.name;
|
||||||
|
if (!rel && skipTop(e.name)) continue;
|
||||||
|
if (e.isDirectory()) await rec(r);
|
||||||
|
else if (e.isFile() && isTransferPath(r)) out.push({ path: r, ...(await hashFile(path.join(dataDir, r))) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await rec('');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadData(g, dataDir, files, onProgress = () => {}) {
|
||||||
|
let sent = 0;
|
||||||
|
const total = files.reduce((n, f) => n + f.size, 0);
|
||||||
|
for (const f of files) {
|
||||||
|
onProgress({ phase: 'upload', file: f.path, sent, total });
|
||||||
|
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { authorization: g.auth, 'content-length': String(f.size) },
|
||||||
|
body: fs.createReadStream(transferTarget(dataDir, f.path)),
|
||||||
|
duplex: 'half',
|
||||||
|
});
|
||||||
|
const r = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new HostedError(res.status, r.error || `upload of ${f.path} failed`);
|
||||||
|
if (r.sha256 !== f.sha256) throw new Error(`${f.path} changed while it was being sent`);
|
||||||
|
sent += f.size;
|
||||||
|
}
|
||||||
|
onProgress({ phase: 'upload', sent, total });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file list comes from the server, so every path is checked before
|
||||||
|
// anything is written, nothing existing is overwritten, and `written` lists
|
||||||
|
// exactly what this run created (the only files a failed move may remove).
|
||||||
|
export async function downloadData(g, dataDir, files, onProgress = () => {}, written = []) {
|
||||||
|
if (!Array.isArray(files) || !files.length) throw new Error('the server sent no files');
|
||||||
|
const targets = files.map((f) => {
|
||||||
|
const t = transferTarget(dataDir, f.path);
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(String(f.sha256))) throw new Error(`no checksum for ${f.path}`);
|
||||||
|
if (fs.existsSync(t)) throw new Error(`${f.path} already exists on this computer`);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
if (!files.some((f) => f.path === 's3d.db')) throw new Error('the server sent no s3d database');
|
||||||
|
let got = 0;
|
||||||
|
const total = files.reduce((n, f) => n + (Number(f.size) || 0), 0);
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i];
|
||||||
|
const target = targets[i];
|
||||||
|
onProgress({ phase: 'download', file: f.path, got, total });
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
const res = await fetch(`${g.base}/hosted/v1/data/file?path=${encodeURIComponent(f.path)}`, { headers: { authorization: g.auth } });
|
||||||
|
if (!res.ok) throw new HostedError(res.status, (await res.json().catch(() => ({}))).error || `download of ${f.path} failed`);
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const src = Readable.fromWeb(res.body);
|
||||||
|
src.on('data', (c) => hash.update(c));
|
||||||
|
written.push(target);
|
||||||
|
await pipeline(src, fs.createWriteStream(target, { flags: 'wx' }));
|
||||||
|
if (hash.digest('hex') !== f.sha256) throw new Error(`${f.path} arrived damaged`);
|
||||||
|
got += Number(f.size) || 0;
|
||||||
|
}
|
||||||
|
onProgress({ phase: 'download', got, total });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { readBody };
|
||||||
|
|
@ -4,16 +4,17 @@
|
||||||
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
const REGION = 'us-east-1';
|
export const REGION = 'us-east-1';
|
||||||
const SERVICE = 's3';
|
export const SERVICE = 's3';
|
||||||
|
|
||||||
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
||||||
const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest();
|
const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest();
|
||||||
|
|
||||||
// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths.
|
// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths.
|
||||||
function uriEncode(str, keepSlash) {
|
export function uriEncode(str, keepSlash) {
|
||||||
const out = encodeURIComponent(str)
|
const out = encodeURIComponent(str)
|
||||||
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
||||||
// (A "never matches" regex like /$^/ is not one: it matches the empty
|
// (A "never matches" regex like /$^/ is not one: it matches the empty
|
||||||
|
|
@ -21,17 +22,17 @@ function uriEncode(str, keepSlash) {
|
||||||
return keepSlash ? out.replace(/%2F/g, '/') : out;
|
return keepSlash ? out.replace(/%2F/g, '/') : out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalQuery(query) {
|
export function canonicalQuery(query) {
|
||||||
return Object.keys(query).sort()
|
return Object.keys(query).sort()
|
||||||
.map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`)
|
.map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`)
|
||||||
.join('&');
|
.join('&');
|
||||||
}
|
}
|
||||||
|
|
||||||
function amzDate(d = new Date()) {
|
export function amzDate(d = new Date()) {
|
||||||
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function signingKey(secret, date) {
|
export function signingKey(secret, date) {
|
||||||
return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request');
|
return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,11 +85,13 @@ export class S3Client {
|
||||||
|
|
||||||
// Low-level request. body: Buffer | string | Readable | undefined.
|
// Low-level request. body: Buffer | string | Readable | undefined.
|
||||||
// Resolves the raw http.IncomingMessage so callers can stream it.
|
// Resolves the raw http.IncomingMessage so callers can stream it.
|
||||||
request(method, path, { query = {}, headers = {}, body } = {}) {
|
request(method, path, { query = {}, headers = {}, body, payloadHash } = {}) {
|
||||||
const signedHeaders = this.sign(method, path, query, headers);
|
const signedHeaders = this.sign(method, path, query, headers, payloadHash ? { payloadHash } : undefined);
|
||||||
const qs = canonicalQuery(query);
|
const qs = canonicalQuery(query);
|
||||||
|
// The hosted gateway is https; a local s3d is plain http on loopback.
|
||||||
|
const transport = this.endpoint.protocol === 'https:' ? https : http;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = http.request({
|
const req = transport.request({
|
||||||
protocol: this.endpoint.protocol,
|
protocol: this.endpoint.protocol,
|
||||||
hostname: this.endpoint.hostname,
|
hostname: this.endpoint.hostname,
|
||||||
port: this.endpoint.port,
|
port: this.endpoint.port,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import * as admin from './admin.js';
|
||||||
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
|
import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js';
|
||||||
import * as accounts from './accounts.js';
|
import * as accounts from './accounts.js';
|
||||||
import { readPrefs, writePrefs } from './prefs.js';
|
import { readPrefs, writePrefs } from './prefs.js';
|
||||||
|
import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js';
|
||||||
|
import { installHosted } from './hosted-routes.js';
|
||||||
|
import { accountInfo, readConnection, fingerprint } from './sia-account.js';
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const UI_DIR = path.join(HERE, '..', 'ui');
|
const UI_DIR = path.join(HERE, '..', 'ui');
|
||||||
|
|
@ -48,6 +51,7 @@ export async function createPithos(opts = {}) {
|
||||||
hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only
|
hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only
|
||||||
openExternal, // (url) => void, for hosts that can open the system browser
|
openExternal, // (url) => void, for hosts that can open the system browser
|
||||||
showPanel, // () => void, for hosts with a side panel to switch back to (Theseus)
|
showPanel, // () => void, for hosts with a side panel to switch back to (Theseus)
|
||||||
|
secrets, // hosted identity + encryption keys; the Theseus vault, else a local key file
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const configFile = resolveConfigPath(configOpt);
|
const configFile = resolveConfigPath(configOpt);
|
||||||
|
|
@ -73,6 +77,7 @@ export async function createPithos(opts = {}) {
|
||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
let hosted = null; // "On Silent Mode", installed below
|
||||||
const broadcast = (event, data) => {
|
const broadcast = (event, data) => {
|
||||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
for (const res of subscribers) res.write(msg);
|
for (const res of subscribers) res.write(msg);
|
||||||
|
|
@ -80,7 +85,8 @@ export async function createPithos(opts = {}) {
|
||||||
daemon.on('status', (s) => {
|
daemon.on('status', (s) => {
|
||||||
// A registration done outside Pithos (s3d login by hand) shows up on the next start.
|
// A registration done outside Pithos (s3d login by hand) shows up on the next start.
|
||||||
if (s.state === 'starting' && !regCache?.registered) regCache = null;
|
if (s.state === 'starting' && !regCache?.registered) regCache = null;
|
||||||
broadcast('status', s);
|
// While drives are hosted the UI follows the server's s3d instead.
|
||||||
|
if (!hosted?.isHosted()) broadcast('status', s);
|
||||||
});
|
});
|
||||||
daemon.on('log', (l) => broadcast('log', l));
|
daemon.on('log', (l) => broadcast('log', l));
|
||||||
login.on('state', (s) => {
|
login.on('state', (s) => {
|
||||||
|
|
@ -125,8 +131,11 @@ export async function createPithos(opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function s3For(user) {
|
async function s3For(user) {
|
||||||
|
if (hosted.isHosted()) return hosted.s3For(user);
|
||||||
const keys = await cli.listKeys(user);
|
const keys = await cli.listKeys(user);
|
||||||
if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`);
|
if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`);
|
||||||
|
const decrypting = await hosted.s3For(user, keys);
|
||||||
|
if (decrypting) return decrypting;
|
||||||
const c = cfg();
|
const c = cfg();
|
||||||
return new S3Client({ endpoint: `http://${c.apiAddress}`, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey });
|
return new S3Client({ endpoint: `http://${c.apiAddress}`, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey });
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +158,7 @@ export async function createPithos(opts = {}) {
|
||||||
autostart('Pithos was opened');
|
autostart('Pithos was opened');
|
||||||
}
|
}
|
||||||
const v = await version();
|
const v = await version();
|
||||||
return {
|
const local = {
|
||||||
host: hostName,
|
host: hostName,
|
||||||
pithos: pithosVersion,
|
pithos: pithosVersion,
|
||||||
daemon: daemon.status(),
|
daemon: daemon.status(),
|
||||||
|
|
@ -159,6 +168,7 @@ export async function createPithos(opts = {}) {
|
||||||
login: login.snapshot(),
|
login: login.snapshot(),
|
||||||
registration: await registration(),
|
registration: await registration(),
|
||||||
};
|
};
|
||||||
|
return hosted.isHosted() ? hosted.hostedStatus(local) : local;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start and Stop also record what the user wants, so s3d comes back by
|
// Start and Stop also record what the user wants, so s3d comes back by
|
||||||
|
|
@ -257,15 +267,36 @@ export async function createPithos(opts = {}) {
|
||||||
// Upload pipeline
|
// Upload pipeline
|
||||||
// Sia account: which one this s3d uses, a user-set label (Pithos cannot
|
// Sia account: which one this s3d uses, a user-set label (Pithos cannot
|
||||||
// see the account's email), switching to another and restoring an old one.
|
// see the account's email), switching to another and restoring an old one.
|
||||||
route('GET', '/api/account', async () => {
|
// Which Sia account this is, as the indexer sees it (public key, storage,
|
||||||
|
// last use), so it can be matched to an app on the sia.storage dashboard.
|
||||||
|
// Asked at most once a minute; ?fresh=1 skips the cache.
|
||||||
|
let siaCache = null;
|
||||||
|
async function siaAccount(dir, fresh) {
|
||||||
|
if (!fresh && siaCache && siaCache.dir === dir && Date.now() - siaCache.at < 60_000) return siaCache.info;
|
||||||
|
let info;
|
||||||
|
try { info = await accountInfo(dir); } catch (e) { info = { error: e.message }; }
|
||||||
|
siaCache = { dir, at: Date.now(), info };
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
// An archive's key is read from its own s3d.db, without asking anyone.
|
||||||
|
async function archiveKey(dir) {
|
||||||
|
try {
|
||||||
|
const c = await readConnection(dir);
|
||||||
|
return c?.appKey ? fingerprint(c.appKey.subarray(32).toString('hex')) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
route('GET', '/api/account', async (req, url) => {
|
||||||
const c = cfg();
|
const c = cfg();
|
||||||
const reg = await registration();
|
const reg = await registration();
|
||||||
|
const archives = accounts.listArchives(c.directory);
|
||||||
|
for (const a of archives) a.fingerprint = await archiveKey(path.join(c.directory, a.name));
|
||||||
return {
|
return {
|
||||||
indexerUrl: reg?.indexerUrl || null,
|
indexerUrl: reg?.indexerUrl || null,
|
||||||
registered: !!reg?.registered,
|
registered: !!reg?.registered,
|
||||||
label: accounts.readLabel(c.directory),
|
label: accounts.readLabel(c.directory),
|
||||||
dataDir: c.directory,
|
dataDir: c.directory,
|
||||||
archives: accounts.listArchives(c.directory),
|
sia: reg?.registered ? await siaAccount(c.directory, url.searchParams.get('fresh') === '1') : null,
|
||||||
|
archives,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
route('PUT', '/api/account/label', async (req) => {
|
route('PUT', '/api/account/label', async (req) => {
|
||||||
|
|
@ -304,6 +335,27 @@ export async function createPithos(opts = {}) {
|
||||||
route('GET', '/api/stats', () => admin.uploadStats(cfg()));
|
route('GET', '/api/stats', () => admin.uploadStats(cfg()));
|
||||||
route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; });
|
route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; });
|
||||||
|
|
||||||
|
const autoFlush = createAutoFlush({
|
||||||
|
settings: () => {
|
||||||
|
const p = readPrefs(configFile);
|
||||||
|
return { enabled: p.autoFlush === true, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES };
|
||||||
|
},
|
||||||
|
// Only an s3d that is up can be asked; a stopped one just means no flush.
|
||||||
|
stats: () => (daemon.state === 'starting' || daemon.state === 'stopping'
|
||||||
|
? Promise.reject(new Error('busy')) : admin.uploadStats(cfg())),
|
||||||
|
flush: () => admin.flush(cfg()),
|
||||||
|
log: (line) => daemon.pushLog('sys', line),
|
||||||
|
});
|
||||||
|
route('GET', '/api/autoflush', () => autoFlush.status());
|
||||||
|
route('PUT', '/api/autoflush', async (req) => {
|
||||||
|
const body = await jsonBody(req);
|
||||||
|
const patch = {};
|
||||||
|
if (body.enabled !== undefined) patch.autoFlush = body.enabled === true;
|
||||||
|
if (body.minutes !== undefined) patch.autoFlushMinutes = clampMinutes(body.minutes);
|
||||||
|
writePrefs(configFile, patch);
|
||||||
|
return autoFlush.status();
|
||||||
|
});
|
||||||
|
|
||||||
// Buckets & objects, acting as one s3d user.
|
// Buckets & objects, acting as one s3d user.
|
||||||
route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets());
|
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) => {
|
route('POST', '/api/s3/:user/buckets', async (req, url, p) => {
|
||||||
|
|
@ -342,6 +394,8 @@ export async function createPithos(opts = {}) {
|
||||||
// Objects are user content: never let them run script on this origin.
|
// 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'";
|
headers['content-security-policy'] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'";
|
||||||
res.writeHead(up.statusCode, headers);
|
res.writeHead(up.statusCode, headers);
|
||||||
|
// A decryption failure mid-file must not look like a complete download.
|
||||||
|
up.on('error', () => res.destroy());
|
||||||
up.pipe(res);
|
up.pipe(res);
|
||||||
return STREAMED;
|
return STREAMED;
|
||||||
});
|
});
|
||||||
|
|
@ -390,15 +444,22 @@ export async function createPithos(opts = {}) {
|
||||||
|
|
||||||
route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => {
|
route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => {
|
||||||
const q = url.searchParams;
|
const q = url.searchParams;
|
||||||
|
if (hosted.isHosted()) return { url: await hosted.shareLink(p.user, p.bucket, requireKey(url), Number(q.get('expires') || 3600)) };
|
||||||
const client = await s3For(p.user);
|
const client = await s3For(p.user);
|
||||||
return { url: client.presign(p.bucket, requireKey(url), Number(q.get('expires') || 3600), q.get('endpoint') || undefined) };
|
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) => {
|
route('GET', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
|
||||||
|
if (hosted.isHosted()) return hosted.drivePolicy(p.bucket);
|
||||||
const policy = await (await s3For(p.user)).getPolicy(p.bucket);
|
const policy = await (await s3For(p.user)).getPolicy(p.bucket);
|
||||||
return { policy, public: describePolicy(policy) };
|
return { policy, public: describePolicy(policy) };
|
||||||
});
|
});
|
||||||
route('PUT', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
|
route('PUT', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => {
|
||||||
const { mode } = await jsonBody(req); // 'private' | 'read' | 'read-list'
|
const { mode } = await jsonBody(req); // 'private' | 'read' | 'read-list'
|
||||||
|
if (hosted.isHosted()) {
|
||||||
|
if (!['private', 'read', 'read-list'].includes(mode)) throw new HttpError(400, 'mode must be private, read or read-list');
|
||||||
|
await hosted.setDrivePolicy(p.user, p.bucket, mode);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
const client = await s3For(p.user);
|
const client = await s3For(p.user);
|
||||||
if (mode === 'private') { await client.deletePolicy(p.bucket).catch((e) => { if (e.status !== 404) throw e; }); }
|
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 if (mode === 'read' || mode === 'read-list') await client.putPolicy(p.bucket, publicReadPolicy(p.bucket, { list: mode === 'read-list' }));
|
||||||
|
|
@ -406,6 +467,12 @@ export async function createPithos(opts = {}) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
hosted = installHosted({
|
||||||
|
route, configFile, cfg, daemon, broadcast, registration, HttpError, secrets,
|
||||||
|
resetRegistration: () => { regCache = null; },
|
||||||
|
hasSubscribers: () => subscribers.size > 0,
|
||||||
|
});
|
||||||
|
|
||||||
// ---- server ------------------------------------------------------------
|
// ---- server ------------------------------------------------------------
|
||||||
|
|
||||||
const STREAMED = Symbol('streamed');
|
const STREAMED = Symbol('streamed');
|
||||||
|
|
@ -419,7 +486,8 @@ export async function createPithos(opts = {}) {
|
||||||
let siteUsersCache = { at: 0, set: new Set() };
|
let siteUsersCache = { at: 0, set: new Set() };
|
||||||
async function siteUsers() {
|
async function siteUsers() {
|
||||||
if (Date.now() - siteUsersCache.at > 10_000) {
|
if (Date.now() - siteUsersCache.at > 10_000) {
|
||||||
try { siteUsersCache = { at: Date.now(), set: new Set(await cli.listUsers()) }; }
|
// Hosted drives' users live on the server; the local s3d has none then.
|
||||||
|
try { siteUsersCache = { at: Date.now(), set: new Set(hosted?.isHosted() ? await hosted.userNames() : await cli.listUsers()) }; }
|
||||||
catch { siteUsersCache.at = Date.now(); } // keep the last good list
|
catch { siteUsersCache.at = Date.now(); } // keep the last good list
|
||||||
}
|
}
|
||||||
return siteUsersCache.set;
|
return siteUsersCache.set;
|
||||||
|
|
@ -504,9 +572,11 @@ export async function createPithos(opts = {}) {
|
||||||
if (!viaTicket && !cookieSession(req)) throw new HttpError(401, 'not signed in');
|
if (!viaTicket && !cookieSession(req)) throw new HttpError(401, 'not signed in');
|
||||||
if (req.method !== 'GET') requireCsrf(req);
|
if (req.method !== 'GET') requireCsrf(req);
|
||||||
|
|
||||||
|
if (await hosted.intercept(req, res, url, (out) => sendJson(res, 200, out))) return;
|
||||||
|
|
||||||
if (url.pathname === '/api/events') {
|
if (url.pathname === '/api/events') {
|
||||||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
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: status\ndata: ${JSON.stringify(hosted.isHosted() ? hosted.remoteDaemon() : daemon.status())}\n\n`);
|
||||||
res.write(`event: login\ndata: ${JSON.stringify(login.snapshot())}\n\n`);
|
res.write(`event: login\ndata: ${JSON.stringify(login.snapshot())}\n\n`);
|
||||||
subscribers.add(res);
|
subscribers.add(res);
|
||||||
const ping = setInterval(() => res.write(': ping\n\n'), 25_000);
|
const ping = setInterval(() => res.write(': ping\n\n'), 25_000);
|
||||||
|
|
@ -566,6 +636,8 @@ export async function createPithos(opts = {}) {
|
||||||
setTimeout(() => autostart('it stopped unexpectedly'), 5000).unref?.();
|
setTimeout(() => autostart('it stopped unexpectedly'), 5000).unref?.();
|
||||||
});
|
});
|
||||||
setTimeout(() => autostart('it was running before'), 500).unref?.();
|
setTimeout(() => autostart('it was running before'), 500).unref?.();
|
||||||
|
const autoFlushTimer = setInterval(() => { autoFlush.tick(); }, 30_000);
|
||||||
|
autoFlushTimer.unref?.();
|
||||||
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
||||||
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
|
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
|
||||||
|
|
||||||
|
|
@ -599,6 +671,8 @@ export async function createPithos(opts = {}) {
|
||||||
return `${baseUrl}dl/${id}`;
|
return `${baseUrl}dl/${id}`;
|
||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
|
clearInterval(autoFlushTimer);
|
||||||
|
hosted.close();
|
||||||
login.cancel();
|
login.cancel();
|
||||||
for (const s of subscribers) s.end();
|
for (const s of subscribers) s.end();
|
||||||
await daemon.stop();
|
await daemon.stop();
|
||||||
|
|
|
||||||
98
bundled-addons/pithos/core/sia-account.js
Normal file
98
bundled-addons/pithos/core/sia-account.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Which Sia account is this s3d using? s3d has no command or admin route that
|
||||||
|
// says, but after `s3d login` it keeps the app key and indexer URL in s3d.db
|
||||||
|
// (global_settings). With them Pithos can ask the indexer itself: GET
|
||||||
|
// /account, signed the way indexd's app API expects (indexd api/app/auth.go):
|
||||||
|
//
|
||||||
|
// query sc = app public key, ss = signature, sv = expiry (unix seconds);
|
||||||
|
// keys and signature are base64 with the URL-safe alphabet AND padding
|
||||||
|
// signed blake2b-256( method | host | path | u64le(expiry) ), no separators,
|
||||||
|
// host and path taken from the stored indexer URL
|
||||||
|
//
|
||||||
|
// The answer carries the account's public key, storage used, quota and the
|
||||||
|
// last time it was used, which is what lets a person match this s3d to an app
|
||||||
|
// on their sia.storage dashboard. The key never leaves this process.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { blake2b256 } from './blake2b.js';
|
||||||
|
|
||||||
|
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
||||||
|
|
||||||
|
// node:sqlite is built into Node 22.5+ (and the Electron that Theseus ships);
|
||||||
|
// loaded lazily so an older runtime only loses this feature.
|
||||||
|
async function openSqlite(file) {
|
||||||
|
const { DatabaseSync } = await import('node:sqlite');
|
||||||
|
return new DatabaseSync(file, { readOnly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// { appKey: Buffer(64) | null, indexerUrl } from the data folder, or null when
|
||||||
|
// there is no s3d database yet. s3d runs SQLite in WAL mode without exclusive
|
||||||
|
// locking, so reading while it runs is safe.
|
||||||
|
export async function readConnection(dataDir) {
|
||||||
|
const file = path.join(dataDir, 's3d.db');
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const db = await openSqlite(file);
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT app_key, indexer_url FROM global_settings LIMIT 1').get();
|
||||||
|
if (!row) return { appKey: null, indexerUrl: null };
|
||||||
|
const key = row.app_key ? Buffer.from(row.app_key) : null;
|
||||||
|
return { appKey: key && key.length === 64 ? key : null, indexerUrl: row.indexer_url || null };
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64 = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_');
|
||||||
|
|
||||||
|
// The query string for a signed app-API request (exported for tests).
|
||||||
|
export function signRequest(appKey, method, endpoint, validUntil) {
|
||||||
|
const u = new URL(endpoint);
|
||||||
|
const exp = Buffer.alloc(8);
|
||||||
|
exp.writeBigUInt64LE(BigInt(validUntil));
|
||||||
|
const digest = blake2b256(Buffer.concat([Buffer.from(method), Buffer.from(u.host), Buffer.from(u.pathname), exp]));
|
||||||
|
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, appKey.subarray(0, 32)]), format: 'der', type: 'pkcs8' });
|
||||||
|
const sig = crypto.sign(null, digest, key);
|
||||||
|
u.searchParams.set('sv', String(validUntil));
|
||||||
|
u.searchParams.set('sc', b64(appKey.subarray(32)));
|
||||||
|
u.searchParams.set('ss', b64(sig));
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short, stable label for an account key: the first 8 hex characters as
|
||||||
|
// "ed25519:1a2b3c4d…" (the same form indexd prints, cut short).
|
||||||
|
export const fingerprint = (pubHex) => `ed25519:${pubHex.slice(0, 8)}…${pubHex.slice(-4)}`;
|
||||||
|
|
||||||
|
// What the indexer knows about this s3d's account. Throws with a readable
|
||||||
|
// message on failure; `connection` lets callers skip re-reading the db.
|
||||||
|
export async function accountInfo(dataDir, { fetchImpl = fetch, timeoutMs = 10_000, connection } = {}) {
|
||||||
|
const c = connection || await readConnection(dataDir);
|
||||||
|
if (!c?.appKey || !c.indexerUrl) return { connected: false };
|
||||||
|
const publicKey = c.appKey.subarray(32).toString('hex');
|
||||||
|
const base = { connected: true, indexerUrl: c.indexerUrl, publicKey, fingerprint: fingerprint(publicKey) };
|
||||||
|
const endpoint = c.indexerUrl.replace(/\/$/, '') + '/account';
|
||||||
|
const url = signRequest(c.appKey, 'GET', endpoint, Math.floor(Date.now() / 1000) + 600);
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: ctl.signal });
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) return { ...base, error: `the indexer answered ${res.status}: ${text.trim().slice(0, 200)}` };
|
||||||
|
const a = JSON.parse(text);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
accountKey: a.accountKey,
|
||||||
|
app: a.app ? { name: a.app.name, description: a.app.description, id: a.app.id } : null,
|
||||||
|
pinnedData: a.pinnedData ?? null,
|
||||||
|
pinnedSize: a.pinnedSize ?? null,
|
||||||
|
maxPinnedData: a.maxPinnedData ?? null,
|
||||||
|
remainingStorage: a.remainingStorage ?? null,
|
||||||
|
ready: a.ready ?? null,
|
||||||
|
lastUsed: a.lastUsed || null,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { ...base, error: e.name === 'AbortError' ? 'the indexer did not answer' : e.message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,15 @@ module.exports = {
|
||||||
openExternal: (url) => api.openTab(url),
|
openExternal: (url) => api.openTab(url),
|
||||||
// "Back to sidebar" from the full-tab view.
|
// "Back to sidebar" from the full-tab view.
|
||||||
showPanel: () => api.revealSidebar("main"),
|
showPanel: () => api.revealSidebar("main"),
|
||||||
|
// "On Silent Mode": sign-in identity and file encryption keys come
|
||||||
|
// from the vault, so another computer with the same vault reaches
|
||||||
|
// and reads the same drives. NEVER CHANGE THESE PATHS: encrypted
|
||||||
|
// files would become unreadable.
|
||||||
|
secrets: {
|
||||||
|
kind: "vault",
|
||||||
|
identitySeed: () => vaultKey("pithos/hosted-identity/v1", "Sign in to your drives on Silent Mode."),
|
||||||
|
encryptionKey: () => vaultKey("pithos/hosted-encryption/v1", "Open your encrypted files on Silent Mode."),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
cookie = p.internalSession();
|
cookie = p.internalSession();
|
||||||
pithos = p;
|
pithos = p;
|
||||||
|
|
@ -196,6 +205,20 @@ module.exports = {
|
||||||
|
|
||||||
api.onMessage("vault-status", () => vaultStatus());
|
api.onMessage("vault-status", () => vaultStatus());
|
||||||
|
|
||||||
|
async function vaultKey(purpose, reason) {
|
||||||
|
const st = await vaultStatus();
|
||||||
|
if (!st.setup) throw new Error("Set up the password vault in Settings › Passwords first.");
|
||||||
|
if (!st.unlocked) {
|
||||||
|
if (!canPrompt) throw new Error("Unlock the password vault in Settings › Passwords first.");
|
||||||
|
const r = await vault.requestUnlock({ reason });
|
||||||
|
if (!r.ok) throw new Error("The vault stayed locked.");
|
||||||
|
}
|
||||||
|
const bytes = await vault.derive(purpose);
|
||||||
|
const key = Buffer.from(bytes.subarray(0, 32));
|
||||||
|
bytes.fill(0);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for the vault locking (Settings › Lock now, or a lock from another
|
// Watch for the vault locking (Settings › Lock now, or a lock from another
|
||||||
// extension) and tell the panel at once. This runs in the main process:
|
// extension) and tell the panel at once. This runs in the main process:
|
||||||
// a hidden panel's own timers are throttled to about once a minute.
|
// a hidden panel's own timers are throttled to about once a minute.
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,8 @@ const eventHandlers = {
|
||||||
},
|
},
|
||||||
login(d) { state.login = d; emit(); },
|
login(d) { state.login = d; emit(); },
|
||||||
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
|
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
|
||||||
|
move(p) { progressSink?.(p); },
|
||||||
|
convert(p) { progressSink?.(p); },
|
||||||
};
|
};
|
||||||
|
|
||||||
function connectEvents() {
|
function connectEvents() {
|
||||||
|
|
@ -183,7 +185,7 @@ function renderPill() {
|
||||||
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 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');
|
const pill = $('#daemon-pill');
|
||||||
pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`;
|
pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`;
|
||||||
pill.querySelector('.label').textContent = label;
|
pill.querySelector('.label').textContent = isHosted() ? `On Silent Mode · ${label}` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- router
|
// ---------------------------------------------------------------- router
|
||||||
|
|
@ -262,6 +264,23 @@ function overview(main) {
|
||||||
const st = daemonState();
|
const st = daemonState();
|
||||||
const busy = st === 'starting' || st === 'stopping';
|
const busy = st === 'starting' || st === 'stopping';
|
||||||
const running = st === 'running' || st === 'starting';
|
const running = st === 'running' || st === 'starting';
|
||||||
|
if (s.mode === 'hosted') {
|
||||||
|
put(daemonCard,
|
||||||
|
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway on Silent Mode'), h('span', { class: `badge ${st === 'running' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
||||||
|
h('dl', { class: 'kv' },
|
||||||
|
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · your own, connected from this computer'),
|
||||||
|
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, hostedUrl()), h('span', { class: 'small muted' }, ' (path-style)')),
|
||||||
|
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : '–'),
|
||||||
|
h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : 'starts on the next request'),
|
||||||
|
h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(s.hosted?.usage?.down || 0)} this month${s.hosted?.downloadCapBytes ? ` of ${fmtBytes(s.hosted.downloadCapBytes)}` : ''}`),
|
||||||
|
),
|
||||||
|
h('p', { class: 'small muted' }, 'Files are encrypted on this computer before they reach the server. s3d there starts when something asks for your drives and stops when idle.'),
|
||||||
|
h('div', { class: 'row', style: 'margin-top:16px' },
|
||||||
|
h('button', { class: 'btn primary', disabled: busy || running, onclick: () => daemonAction('start') }, 'Start'),
|
||||||
|
h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'),
|
||||||
|
h('a', { class: 'btn', href: '#/settings' }, 'Where your drives live')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
put(daemonCard,
|
put(daemonCard,
|
||||||
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
|
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('dl', { class: 'kv' },
|
||||||
|
|
@ -293,6 +312,7 @@ function overview(main) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const x = await api('GET', '/api/stats');
|
const x = await api('GET', '/api/stats');
|
||||||
|
const af = await api('GET', '/api/autoflush').catch(() => null);
|
||||||
const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l));
|
const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l));
|
||||||
put(statsCard,
|
put(statsCard,
|
||||||
h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'),
|
h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'),
|
||||||
|
|
@ -306,12 +326,24 @@ function overview(main) {
|
||||||
stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 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.'),
|
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.'),
|
||||||
|
af && h('p', { class: 'small muted', style: 'margin:6px 0 0' }, autoFlushLine(af, x), ' ', h('a', { href: '#/settings' }, af.enabled ? 'Change' : 'Turn on')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
|
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function autoFlushLine(af, x) {
|
||||||
|
if (!af.enabled) return 'Leftovers upload only when a block fills up or you flush.';
|
||||||
|
if (af.flushing) return 'Uploading leftovers now…';
|
||||||
|
if (af.lastError && x.pendingObjects) return `The last automatic upload failed (${af.lastError}). Pithos will try again later.`;
|
||||||
|
if (x.pendingObjects && af.dueAt) {
|
||||||
|
const min = Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000));
|
||||||
|
return `Leftovers upload automatically in about ${min} min if nothing new arrives.`;
|
||||||
|
}
|
||||||
|
return `Leftovers upload automatically after ${af.minutes} min with no new uploads.`;
|
||||||
|
}
|
||||||
|
|
||||||
async function flushNow(ev) {
|
async function flushNow(ev) {
|
||||||
const btn = ev.currentTarget;
|
const btn = ev.currentTarget;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
@ -333,6 +365,10 @@ function overview(main) {
|
||||||
|
|
||||||
function bannersFor(s) {
|
function bannersFor(s) {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
if (s.mode === 'hosted') {
|
||||||
|
if (s.hosted?.error) out.push(h('div', { class: 'banner warn' }, h('span', {}, `Silent Mode is not reachable: ${s.hosted.error}`), h('a', { class: 'btn', href: '#/settings' }, 'Settings')));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
if (!s.daemon.binary) {
|
if (!s.daemon.binary) {
|
||||||
out.push(h('div', { class: 'banner warn' },
|
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.`),
|
h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`),
|
||||||
|
|
@ -701,8 +737,9 @@ function otherAccounts(after) {
|
||||||
h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside when another account was added. Switching back restores its users, keys and drives; the current account is set aside the same way.'),
|
h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside when another account was added. Switching back restores its users, keys and drives; the current account is set aside the same way.'),
|
||||||
h('table', {}, h('tbody', {}, list.map((a) => h('tr', {},
|
h('table', {}, h('tbody', {}, list.map((a) => h('tr', {},
|
||||||
h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)),
|
h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)),
|
||||||
h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)),
|
h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.fingerprint ? ` · key ${a.fingerprint}` : ''}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`),
|
||||||
h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restore(a) }, 'Switch to')))))));
|
a.movedTo && h('div', { class: 'small muted' }, `Left behind when these drives moved to ${a.movedTo}; kept as a backup only.`)),
|
||||||
|
h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData || !!a.movedTo, onclick: () => restore(a) }, 'Switch to')))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(main) {
|
function setup(main) {
|
||||||
|
|
@ -731,7 +768,8 @@ function setup(main) {
|
||||||
h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'),
|
h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'),
|
||||||
h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label,
|
h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label,
|
||||||
h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
|
h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
|
||||||
h('small', {}, 'Pithos cannot see which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".')),
|
h('small', {}, 'Pithos cannot see the login email, so name it here: the email you approved the connection with is a good label.')),
|
||||||
|
siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }),
|
||||||
h('div', { class: 'row', style: 'margin-top:16px' },
|
h('div', { class: 'row', style: 'margin-top:16px' },
|
||||||
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'),
|
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'),
|
||||||
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
|
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
|
||||||
|
|
@ -956,7 +994,7 @@ function users(main) {
|
||||||
|
|
||||||
function showKey(k, fresh) {
|
function showKey(k, fresh) {
|
||||||
const s = state.status;
|
const s = state.status;
|
||||||
const endpoint = `http://${s?.config.apiAddress || '127.0.0.1:8000'}`;
|
const endpoint = s?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000';
|
||||||
const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}
|
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 aws_secret_access_key ${k.secretKey} --profile ${k.user}
|
||||||
aws configure set region us-east-1 --profile ${k.user}
|
aws configure set region us-east-1 --profile ${k.user}
|
||||||
|
|
@ -1008,7 +1046,7 @@ async function passwordKey(password, salt, iterations) {
|
||||||
async function sealCredentials(k, { mode, password }) {
|
async function sealCredentials(k, { mode, password }) {
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
user: k.user, accessKeyId: k.accessKeyId, secretKey: k.secretKey,
|
user: k.user, accessKeyId: k.accessKeyId, secretKey: k.secretKey,
|
||||||
endpoint: `http://${state.status?.config.apiAddress || '127.0.0.1:8000'}`, region: 'us-east-1',
|
endpoint: state.status?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000', region: 'us-east-1',
|
||||||
});
|
});
|
||||||
const head = { format: CRED_FORMAT, v: 1, user: k.user, saved: new Date().toISOString() };
|
const head = { format: CRED_FORMAT, v: 1, user: k.user, saved: new Date().toISOString() };
|
||||||
if (mode === 'vault') {
|
if (mode === 'vault') {
|
||||||
|
|
@ -1215,7 +1253,33 @@ async function loadAccount() {
|
||||||
try { accountInfo = await api('GET', '/api/account'); } catch { accountInfo = null; }
|
try { accountInfo = await api('GET', '/api/account'); } catch { accountInfo = null; }
|
||||||
return accountInfo;
|
return accountInfo;
|
||||||
}
|
}
|
||||||
const accountName = (a) => (a && (a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected';
|
const accountName = (a) => (a && (a.label || a.sia?.fingerprint || (a.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected';
|
||||||
|
|
||||||
|
// What the indexer says about this s3d's Sia account. The figures are the
|
||||||
|
// ones sia.storage shows for the app, which is how a person tells which of
|
||||||
|
// their accounts (or apps) this is.
|
||||||
|
function siaAccountBlock(a, onRefresh) {
|
||||||
|
const sia = a?.sia;
|
||||||
|
if (!sia?.connected) return null;
|
||||||
|
const used = sia.pinnedData != null
|
||||||
|
? `${fmtBytes(sia.pinnedData)}${sia.maxPinnedData ? ` of ${fmtBytes(sia.maxPinnedData)}` : ''}`
|
||||||
|
: '–';
|
||||||
|
return h('div', { class: 'card', style: 'margin:14px 0;background:var(--surface-2)' },
|
||||||
|
h('div', { class: 'row spread' }, h('h3', { style: 'margin:0' }, 'Which Sia account this is'),
|
||||||
|
onRefresh && h('button', { class: 'btn small', type: 'button', onclick: onRefresh }, 'Check again')),
|
||||||
|
sia.error && h('p', { class: 'small error' }, `The indexer did not answer: ${sia.error}`),
|
||||||
|
h('dl', { class: 'kv' },
|
||||||
|
h('dt', {}, 'Account key'), h('dd', {}, h('code', { title: sia.publicKey }, sia.fingerprint), ' ',
|
||||||
|
h('button', { class: 'btn small', type: 'button', onclick: () => copy(`ed25519:${sia.publicKey}`, 'Account key copied') }, 'Copy')),
|
||||||
|
h('dt', {}, 'App'), h('dd', {}, sia.app?.name || 'S3d'),
|
||||||
|
h('dt', {}, 'Stored'), h('dd', {}, used),
|
||||||
|
h('dt', {}, 'Last used'), h('dd', {}, sia.lastUsed ? fmtDate(sia.lastUsed) : '–'),
|
||||||
|
h('dt', {}, 'Indexer'), h('dd', {}, h('code', {}, sia.indexerUrl))),
|
||||||
|
h('p', { class: 'small muted', style: 'margin:8px 0 0' },
|
||||||
|
'On sia.storage, the account whose app "', sia.app?.name || 'S3d', '" shows this storage and last-used time is this one. ',
|
||||||
|
'The account is decided by two things together: the sia.storage login that approved the connection, and the recovery phrase. ',
|
||||||
|
'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.'));
|
||||||
|
}
|
||||||
|
|
||||||
// "Sia account: …" line for page headers; fills itself in.
|
// "Sia account: …" line for page headers; fills itself in.
|
||||||
function accountLine() {
|
function accountLine() {
|
||||||
|
|
@ -1278,7 +1342,7 @@ async function bucketList(head, body, user, userSel) {
|
||||||
function accessBadge(bucketName) {
|
function accessBadge(bucketName) {
|
||||||
const el = h('span', {}, h('span', { class: 'badge' }, '…'));
|
const el = h('span', {}, h('span', { class: 'badge' }, '…'));
|
||||||
api('GET', `${base}/${encodeURIComponent(bucketName)}/policy`).then((p) => {
|
api('GET', `${base}/${encodeURIComponent(bucketName)}/policy`).then((p) => {
|
||||||
put(el, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public]));
|
put(el, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: isHosted() ? '🔒 encrypted' : '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public]));
|
||||||
}).catch(() => put(el, '?'));
|
}).catch(() => put(el, '?'));
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
@ -1309,6 +1373,7 @@ async function bucketList(head, body, user, userSel) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accessDialog(user, bucket) {
|
async function accessDialog(user, bucket) {
|
||||||
|
if (isHosted()) return hostedAccessDialog(user, bucket);
|
||||||
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`;
|
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`;
|
||||||
let current = 'private';
|
let current = 'private';
|
||||||
try { current = (await api('GET', base)).public; } catch (e) { return fail(e); }
|
try { current = (await api('GET', base)).public; } catch (e) { return fail(e); }
|
||||||
|
|
@ -1387,7 +1452,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
}
|
}
|
||||||
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
|
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
|
||||||
const fileName = (o) => o.key.slice(prefix.length);
|
const fileName = (o) => o.key.slice(prefix.length);
|
||||||
const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]);
|
const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Delete', () => deleteFolder(f), 'danger']]);
|
||||||
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
|
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
|
||||||
const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'));
|
const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'));
|
||||||
const stop = (e) => e.stopPropagation();
|
const stop = (e) => e.stopPropagation();
|
||||||
|
|
@ -1405,7 +1470,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
h('td', { class: 'num muted hide-sm' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }),
|
h('td', { class: 'num muted hide-sm' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }),
|
||||||
h('td', { class: 'actions', onclick: stop }, folderMenu(f)))),
|
h('td', { class: 'actions', onclick: stop }, folderMenu(f)))),
|
||||||
objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||||||
h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)))),
|
h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o), readableMark(o)))),
|
||||||
h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
|
h('td', { class: 'num hide-sm' }, fmtBytes(o.size)),
|
||||||
h('td', { class: 'hide-sm muted' }, fileType(o.key)),
|
h('td', { class: 'hide-sm muted' }, fileType(o.key)),
|
||||||
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
||||||
|
|
@ -1420,7 +1485,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)), h('span', { class: 'meta' }),
|
h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)), h('span', { class: 'meta' }),
|
||||||
h('span', { onclick: stop }, folderMenu(f)))),
|
h('span', { onclick: stop }, folderMenu(f)))),
|
||||||
objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||||||
h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)),
|
h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o), readableMark(o)),
|
||||||
h('span', { class: 'meta hide-sm' }, fmtBytes(o.size)),
|
h('span', { class: 'meta hide-sm' }, fmtBytes(o.size)),
|
||||||
h('span', { class: 'row-actions', onclick: stop }, quickIcons(o), fileMenu(o))))),
|
h('span', { class: 'row-actions', onclick: stop }, quickIcons(o), fileMenu(o))))),
|
||||||
more);
|
more);
|
||||||
|
|
@ -1432,7 +1497,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
h('div', { class: 'tile-more', onclick: stop }, folderMenu(f)))),
|
h('div', { class: 'tile-more', onclick: stop }, folderMenu(f)))),
|
||||||
objects.map((o) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
objects.map((o) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||||||
thumbFor(o),
|
thumbFor(o),
|
||||||
h('div', { class: 'tile-name', title: fileName(o) }, fileName(o)),
|
h('div', { class: 'tile-name', title: fileName(o) }, fileName(o), readableMark(o)),
|
||||||
h('div', { class: 'small muted' }, fmtBytes(o.size)),
|
h('div', { class: 'small muted' }, fmtBytes(o.size)),
|
||||||
h('div', { class: 'tile-more', onclick: stop }, fileMenu(o))))),
|
h('div', { class: 'tile-more', onclick: stop }, fileMenu(o))))),
|
||||||
more);
|
more);
|
||||||
|
|
@ -1459,6 +1524,18 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Silent Mode, files the server can read: shared on purpose, or stored
|
||||||
|
// before the drive moved there.
|
||||||
|
let shareRules = [];
|
||||||
|
if (isHosted()) api('GET', '/api/hosted/rules').then((r) => { shareRules = r; draw(); }).catch(() => {});
|
||||||
|
function readableMark(o) {
|
||||||
|
if (!isHosted() || o.encrypted !== false) return null;
|
||||||
|
const shared = shareRules.some((r) => r.bucket === bucket && (r.type === 'link' ? r.prefix === o.key : o.key.startsWith(r.prefix)));
|
||||||
|
return shared
|
||||||
|
? h('span', { class: 'small muted', title: 'Shared, so stored readable on the server' }, ' 🌐')
|
||||||
|
: h('span', { class: 'small', title: 'Stored readable on the server (from before your drives moved). Encrypt it under Access.' }, ' 🔓');
|
||||||
|
}
|
||||||
|
|
||||||
function uploadFiles(files) {
|
function uploadFiles(files) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; }
|
if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; }
|
||||||
|
|
@ -1564,6 +1641,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
// validity or address changes. s3d listens only on this computer, so
|
// validity or address changes. s3d listens only on this computer, so
|
||||||
// without an address of your own the link opens only here; it says so.
|
// without an address of your own the link opens only here; it says so.
|
||||||
async function share(key) {
|
async function share(key) {
|
||||||
|
if (isHosted()) return hostedShare(key);
|
||||||
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l)));
|
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l)));
|
||||||
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
|
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
|
||||||
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' });
|
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' });
|
||||||
|
|
@ -1596,9 +1674,320 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
[{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]);
|
[{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Silent Mode a link is a rule on the server, and the file is stored
|
||||||
|
// readable while it works.
|
||||||
|
async function hostedShare(key) {
|
||||||
|
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
|
||||||
|
const out = h('div');
|
||||||
|
const gen = async () => {
|
||||||
|
put(out, h('span', { class: 'spinner' }));
|
||||||
|
try {
|
||||||
|
const { url } = await api('GET', `${base}/presign?${new URLSearchParams({ key, expires: expires.value })}`);
|
||||||
|
put(out, secretRow('Link', url));
|
||||||
|
} catch (e) { put(out); fail(e); }
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
await modal('Share link', h('div', { class: 'stack' },
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Anyone with the link can open this file until it ends, even with this computer off. While the link works the file is stored readable on the server; afterwards Pithos encrypts it again.'),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
|
||||||
|
out),
|
||||||
|
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
|
||||||
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- on Silent Mode
|
||||||
|
|
||||||
|
const isHosted = () => state.status?.mode === 'hosted';
|
||||||
|
const hostedUrl = () => state.status?.hosted?.url || 'https://s3.silentmode.st';
|
||||||
|
// Where S3 clients reach this s3d: the server, or the local address.
|
||||||
|
const s3Endpoint = () => (isHosted() ? hostedUrl() : `http://${state.status?.config.apiAddress}`);
|
||||||
|
|
||||||
|
// Long jobs (moving drives, converting folders) report through events.
|
||||||
|
let progressSink = null;
|
||||||
|
function progressLine(p) {
|
||||||
|
if (p.phase === 'flush') return 'Sending leftovers to Sia…';
|
||||||
|
if (p.phase === 'stop') return 'Stopping s3d…';
|
||||||
|
if (p.phase === 'upload') return `Sending the s3d folder… ${fmtBytes(p.sent)} of ${fmtBytes(p.total)}`;
|
||||||
|
if (p.phase === 'download') return `Bringing the s3d folder back… ${fmtBytes(p.got)} of ${fmtBytes(p.total)}`;
|
||||||
|
if (p.phase === 'start') return 'Starting s3d…';
|
||||||
|
if (p.phase === 'done') return 'Done.';
|
||||||
|
if (p.phase === 'failed') return `Failed: ${p.error}`;
|
||||||
|
if (p.total != null) return `Converting files… ${p.done} of ${p.total}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostedCard(card) {
|
||||||
|
let info = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try { info = await api('GET', '/api/hosted'); } catch (e) { put(card, h('h2', {}, 'Where your drives live'), h('p', { class: 'error' }, e.message)); return; }
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const me = info.me;
|
||||||
|
const intro = h('p', { class: 'small muted' },
|
||||||
|
'On this computer, s3d runs here and needs this computer on. On Silent Mode, the same s3d runs on Silent Mode\'s server with your own Sia account: apps, S3 clients and share links work with this computer off, and you can reach your drives from other devices. ',
|
||||||
|
'Your recovery phrase never leaves this computer, and Pithos encrypts file names and contents before they leave, so the server cannot read them. Only folders you make public or share stay readable.');
|
||||||
|
const parts = [h('div', { class: 'row spread' }, h('h2', {}, 'Where your drives live'),
|
||||||
|
h('span', { class: `badge ${info.mode === 'hosted' ? 'ok' : ''}` }, info.mode === 'hosted' ? 'On Silent Mode' : 'On this computer')), intro];
|
||||||
|
|
||||||
|
if (!info.signedIn) {
|
||||||
|
const url = h('input', { type: 'url', value: info.url, autocomplete: 'off' });
|
||||||
|
parts.push(
|
||||||
|
info.error && h('p', { class: 'small error' }, info.error),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Silent Mode server'), url),
|
||||||
|
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: () => signIn(url.value.trim()) }, 'Sign in to Silent Mode')),
|
||||||
|
h('p', { class: 'small muted' }, info.secrets === 'vault'
|
||||||
|
? 'You sign in with a key from your Theseus vault: the same vault on another computer reaches the same drives.'
|
||||||
|
: 'You sign in with a key kept in a file beside s3d.yml. Back it up below: without it, encrypted files cannot be read.'));
|
||||||
|
} else {
|
||||||
|
parts.push(h('dl', { class: 'kv' },
|
||||||
|
h('dt', {}, 'Server'), h('dd', {}, h('code', {}, info.url)),
|
||||||
|
h('dt', {}, 'Account'), h('dd', {}, h('code', {}, info.id), me?.gate?.name ? ` · ${me.gate.name}` : ''),
|
||||||
|
me && [h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(me.usage?.down || 0)} this month${me.downloadCapBytes ? ` of ${fmtBytes(me.downloadCapBytes)}` : ''}`)],
|
||||||
|
me && [h('dt', {}, 'Drives there'), h('dd', {}, { empty: 'none yet', active: 'yes', 'moving-out': 'being moved back' }[me.state] || me.state)]));
|
||||||
|
const status = h('p', { class: 'small muted' });
|
||||||
|
progressSink = (p) => put(status, progressLine(p));
|
||||||
|
const buttons = [];
|
||||||
|
if (info.mode === 'local' && me?.state === 'empty') {
|
||||||
|
buttons.push(h('button', { class: 'btn primary', onclick: moveUp }, 'Move my drives to Silent Mode'));
|
||||||
|
}
|
||||||
|
if (info.mode === 'local' && me?.state === 'active') {
|
||||||
|
buttons.push(h('button', { class: 'btn primary', onclick: () => setMode('hosted') }, 'Use my drives on Silent Mode'));
|
||||||
|
}
|
||||||
|
if (info.mode === 'hosted') {
|
||||||
|
buttons.push(h('button', { class: 'btn', onclick: moveDown }, 'Move my drives back to this computer'));
|
||||||
|
}
|
||||||
|
buttons.push(h('button', { class: 'btn', onclick: signOut }, 'Sign out'));
|
||||||
|
parts.push(h('div', { class: 'row' }, buttons), status);
|
||||||
|
if (info.mode === 'local' && me?.state === 'empty') {
|
||||||
|
parts.push(h('p', { class: 'small muted' }, 'Moving sends s3d\'s database (your drives, users and keys) and any files still waiting for Sia. Files already on Sia are not uploaded again. This computer keeps a copy of the old folder, set aside.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info.secrets === 'file') {
|
||||||
|
parts.push(h('div', { class: 'row', style: 'margin-top:12px' },
|
||||||
|
h('button', { class: 'btn small', onclick: exportKey }, 'Back up my key'),
|
||||||
|
!info.signedIn && h('button', { class: 'btn small', onclick: importKey }, 'Restore a key')));
|
||||||
|
}
|
||||||
|
put(card, ...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(url, signup) {
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/hosted/signin', { url, signup });
|
||||||
|
if (r.needsSignup) return signUp(url, r);
|
||||||
|
toast('Signed in to Silent Mode');
|
||||||
|
load();
|
||||||
|
} catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slot on the server costs memory and traffic, so opening one needs a
|
||||||
|
// BNS name you own, a small deposit, or an invite.
|
||||||
|
async function signUp(url, r) {
|
||||||
|
const tabs = [];
|
||||||
|
if (r.gate.bns) tabs.push('bns');
|
||||||
|
if (r.gate.deposit) tabs.push('deposit');
|
||||||
|
if (r.gate.invite) tabs.push('invite');
|
||||||
|
if (!tabs.length) return toast('This server is not taking new accounts', 'error');
|
||||||
|
let kind = tabs[0];
|
||||||
|
const name = h('input', { type: 'text', placeholder: 'yourname.bch', autocomplete: 'off' });
|
||||||
|
const sig = h('textarea', { rows: 3, placeholder: 'Signature (base64)' });
|
||||||
|
const txid = h('input', { type: 'text', placeholder: 'Transaction id', autocomplete: 'off' });
|
||||||
|
const code = h('input', { type: 'text', placeholder: 'Invite code', autocomplete: 'off' });
|
||||||
|
const panes = {
|
||||||
|
bns: h('div', { class: 'stack' },
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Sign this message with the wallet that owns the name (Electron Cash: Tools › Sign/verify message; Cashonize and Paytaca have the same):'),
|
||||||
|
secretRow('Message', r.bnsMessage),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'BNS name'), name),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Signature'), sig)),
|
||||||
|
deposit: r.gate.deposit && h('div', { class: 'stack' },
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, `Send at least ${r.gate.deposit.minSats.toLocaleString()} sats to the address below, with this memo (OP_RETURN). It is refundable after fair use.`),
|
||||||
|
secretRow('Address', r.gate.deposit.address),
|
||||||
|
secretRow('Memo', r.depositMemo),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Transaction id'), txid, h('small', {}, 'Wait for one confirmation (about ten minutes).'))),
|
||||||
|
invite: h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'Invite code'), code)),
|
||||||
|
};
|
||||||
|
const holder = h('div');
|
||||||
|
const label = { bns: 'BNS name', deposit: 'Deposit', invite: 'Invite' };
|
||||||
|
const switcher = h('div', { class: 'row' }, tabs.map((t) => h('label', { class: 'check' },
|
||||||
|
h('input', { type: 'radio', name: 'gate', checked: t === kind, onchange: () => { kind = t; put(holder, panes[t]); } }), label[t])));
|
||||||
|
put(holder, panes[kind]);
|
||||||
|
const proof = await modal('Open your space on Silent Mode', h('div', { class: 'stack' },
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Each person gets one slot. Choose how to show you are one person:'),
|
||||||
|
tabs.length > 1 && switcher, holder),
|
||||||
|
[{ label: 'Cancel', result: null }, { label: 'Open my space', kind: 'primary', submit: true, value: () => (
|
||||||
|
kind === 'bns' ? { type: 'bns', name: name.value.trim(), signature: sig.value.trim() }
|
||||||
|
: kind === 'deposit' ? { type: 'deposit', txid: txid.value.trim() }
|
||||||
|
: { type: 'invite', code: code.value.trim() }) }]);
|
||||||
|
if (proof) signIn(url, proof);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveUp(ev, force = false) {
|
||||||
|
if (!force && !(await confirmModal('Move your drives to Silent Mode?',
|
||||||
|
'Pithos sends leftovers to Sia, stops s3d here and moves its folder to the server. From then on your drives run on Silent Mode, and new files are encrypted before they leave this computer.', 'Move', 'primary'))) return;
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/hosted/move-up', { force });
|
||||||
|
toast(`Your drives are on Silent Mode (${r.users.length} user${r.users.length === 1 ? '' : 's'})`);
|
||||||
|
await refreshStatus();
|
||||||
|
route();
|
||||||
|
} catch (e) {
|
||||||
|
if (!force && /move anyway/.test(e.message)) {
|
||||||
|
if (await confirmModal('Some uploads have not reached Sia', `${e.message}`, 'Move anyway', 'primary')) return moveUp(ev, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(e);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveDown(ev, force = false) {
|
||||||
|
if (!force && !(await confirmModal('Move your drives back here?',
|
||||||
|
'Silent Mode sends leftovers to Sia, stops its s3d and hands the folder back. s3d then runs on this computer again; files encrypted on Silent Mode stay encrypted and Pithos keeps reading them. Public folders, share links and shared keys stop working.', 'Move back', 'primary'))) return;
|
||||||
|
try {
|
||||||
|
await api('POST', '/api/hosted/move-down', { force });
|
||||||
|
toast('Your drives are back on this computer');
|
||||||
|
await refreshStatus();
|
||||||
|
route();
|
||||||
|
} catch (e) {
|
||||||
|
if (!force && /move anyway/.test(e.message)) {
|
||||||
|
if (await confirmModal('Some uploads have not reached Sia', e.message, 'Move anyway', 'primary')) return moveDown(ev, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(e);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMode(mode) {
|
||||||
|
try { await api('POST', '/api/hosted/mode', { mode }); await refreshStatus(); route(); } catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
if (info.mode === 'hosted' && !(await confirmModal('Sign out?', 'Your drives stay on Silent Mode and keep working for apps and links. This computer shows its own s3d until you sign in again.', 'Sign out', 'primary'))) return;
|
||||||
|
try { await api('POST', '/api/hosted/signout'); await refreshStatus(); route(); } catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportKey() {
|
||||||
|
try {
|
||||||
|
const k = await api('GET', '/api/hosted/secret');
|
||||||
|
const text = JSON.stringify(k, null, 2);
|
||||||
|
if (bridge) { await bridge.invoke('save-file', { name: 'pithos-hosted-key.json', text }); return; }
|
||||||
|
const a = h('a', { href: URL.createObjectURL(new Blob([text], { type: 'application/json' })), download: 'pithos-hosted-key.json' });
|
||||||
|
document.body.append(a); a.click(); a.remove();
|
||||||
|
} catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importKey() {
|
||||||
|
const ta = h('textarea', { rows: 6, placeholder: '{ "v": 1, "secret": "…" }' });
|
||||||
|
const v = await modal('Restore a key', h('div', { class: 'stack' },
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Paste the contents of a pithos-hosted-key.json backup. It replaces the key this computer uses.'),
|
||||||
|
h('div', { class: 'banner warn' }, 'Files encrypted with the current key can only be read with it. Back it up first if anything was encrypted with it.'), ta),
|
||||||
|
[{ label: 'Cancel', result: null }, { label: 'Restore', kind: 'primary', submit: true, value: () => ta.value }]);
|
||||||
|
if (!v) return;
|
||||||
|
try { await api('POST', '/api/hosted/secret', JSON.parse(v)); toast('Key restored'); load(); } catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder and drive sharing on Silent Mode: public, or a key for someone.
|
||||||
|
async function folderShareDialog(user, bucket, folder) {
|
||||||
|
const where = folder ? `${bucket}/${folder}` : bucket;
|
||||||
|
const list = h('div', { class: 'stack' });
|
||||||
|
async function loadRules() {
|
||||||
|
let rules = [];
|
||||||
|
try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket && r.prefix === folder && r.type !== 'link'); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); }
|
||||||
|
if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Not shared: only you can read it, and it is stored encrypted.'));
|
||||||
|
put(list, rules.map((r) => h('div', { class: 'row spread' },
|
||||||
|
h('span', {}, r.type === 'public'
|
||||||
|
? (r.rights === 'read-list' ? '🌐 Public, with a file list' : '🌐 Public')
|
||||||
|
: `🔑 Key ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}${r.label ? ` · ${r.label}` : ''}${r.expires ? ` · until ${fmtDate(r.expires)}` : ''}`),
|
||||||
|
h('button', { class: 'btn small danger', type: 'button', onclick: () => removeRule(r) }, r.type === 'public' ? 'Make private' : 'Revoke'))));
|
||||||
|
}
|
||||||
|
async function removeRule(r) {
|
||||||
|
try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); loadRules(); } catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
const kind = h('select', {},
|
||||||
|
h('option', { value: 'public:read' }, 'Anyone: read files'),
|
||||||
|
h('option', { value: 'public:read-list' }, 'Anyone: read files and see the list'),
|
||||||
|
h('option', { value: 'grant:read' }, 'Someone with a key: read'),
|
||||||
|
h('option', { value: 'grant:add' }, 'Someone with a key: add new files'),
|
||||||
|
h('option', { value: 'grant:edit' }, 'Someone with a key: add, change and delete'));
|
||||||
|
const label = h('input', { type: 'text', placeholder: 'Who it is for (optional)' });
|
||||||
|
const until = h('select', {}, [['', 'No expiry'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days']].map(([v, l]) => h('option', { value: v }, l)));
|
||||||
|
const out = h('div', { class: 'stack' });
|
||||||
|
const progress = h('p', { class: 'small muted', style: 'margin:0' });
|
||||||
|
const add = async () => {
|
||||||
|
const [type, rights] = kind.value.split(':');
|
||||||
|
progressSink = (p) => put(progress, progressLine(p));
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/hosted/rules', {
|
||||||
|
user, type, bucket, prefix: folder, rights, label: label.value.trim(),
|
||||||
|
expires: until.value ? Date.now() + Number(until.value) * 1000 : undefined,
|
||||||
|
});
|
||||||
|
put(progress, '');
|
||||||
|
if (r.type === 'public') {
|
||||||
|
const at = `${hostedUrl()}/p/${state.status.hosted.id}/${bucket}/${folder}`;
|
||||||
|
put(out, secretRow(r.rights === 'read-list' ? 'Public address (lists the folder as JSON)' : 'Public address: add a file name', at),
|
||||||
|
r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.'));
|
||||||
|
}
|
||||||
|
else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'),
|
||||||
|
secretRow('S3 endpoint', hostedUrl()), secretRow('Access key', r.accessKeyId), secretRow('Secret key', r.secretKey),
|
||||||
|
secretRow('Bucket and folder', `${bucket}/${folder}`));
|
||||||
|
loadRules();
|
||||||
|
} catch (e) { put(progress, ''); fail(e); }
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
loadRules();
|
||||||
|
await modal(`Share ${where}`, h('div', { class: 'stack' },
|
||||||
|
list,
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Share with'), kind),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Label'), label),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'Ends'), until),
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Shared files are stored readable on the server, because the people you share with have no decryption key. Stop sharing and Pithos encrypts them again.'),
|
||||||
|
progress, out),
|
||||||
|
[{ label: 'Close', result: true }, { label: 'Share', kind: 'primary', submit: true, value: add }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drive on Silent Mode: everything shared in it, in one place.
|
||||||
|
async function hostedAccessDialog(user, bucket) {
|
||||||
|
const list = h('div', { class: 'stack' });
|
||||||
|
const progress = h('p', { class: 'small muted', style: 'margin:0' });
|
||||||
|
progressSink = (p) => put(progress, progressLine(p));
|
||||||
|
async function load() {
|
||||||
|
let rules = [];
|
||||||
|
try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); }
|
||||||
|
if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Nothing in this drive is shared. Everything is encrypted, and only you can read it.'));
|
||||||
|
const what = (r) => (r.type === 'public' ? (r.rights === 'read-list' ? '🌐 public + list' : '🌐 public')
|
||||||
|
: r.type === 'link' ? `🔗 link until ${fmtDate(r.expires)}`
|
||||||
|
: `🔑 ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}`);
|
||||||
|
put(list, h('table', {}, h('tbody', {}, rules.map((r) => h('tr', {},
|
||||||
|
h('td', {}, h('code', {}, `/${r.prefix}`)),
|
||||||
|
h('td', {}, what(r), r.label ? h('span', { class: 'small muted' }, ` · ${r.label}`) : ''),
|
||||||
|
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', type: 'button', onclick: async () => {
|
||||||
|
try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); load(); } catch (e) { fail(e); }
|
||||||
|
} }, 'Stop')))))));
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
const encryptOld = h('button', { class: 'btn small', type: 'button', onclick: async () => {
|
||||||
|
encryptOld.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/api/hosted/encrypt', { user, bucket });
|
||||||
|
toast(r.converted ? `Encrypted ${r.converted} file${r.converted === 1 ? '' : 's'}` : 'Every private file was already encrypted');
|
||||||
|
} catch (e) { fail(e); }
|
||||||
|
encryptOld.disabled = false;
|
||||||
|
} }, 'Encrypt files stored readable');
|
||||||
|
await modal(`Access for ${bucket}`, h('div', { class: 'stack' },
|
||||||
|
list, progress,
|
||||||
|
h('div', { class: 'row' }, encryptOld, h('span', { class: 'small muted' }, 'Files from before your drives moved here are stored as they were. This encrypts every one that is not shared.')),
|
||||||
|
h('p', { class: 'small muted', style: 'margin:0' }, 'Share a folder from its row in the drive, or the whole drive here. Public files are at ',
|
||||||
|
h('code', {}, `${hostedUrl()}/p/${state.status?.hosted?.id}/${bucket}/<path>`), '.')),
|
||||||
|
[{ label: 'Close', result: true }, { label: 'Share the whole drive…', kind: 'primary', submit: true, value: () => { folderShareDialog(user, bucket, ''); return true; } }]);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- music player
|
// ---------------------------------------------------------------- music player
|
||||||
|
|
||||||
// A player docked at the bottom of Pithos: it lives outside the page, so it
|
// A player docked at the bottom of Pithos: it lives outside the page, so it
|
||||||
|
|
@ -1723,7 +2112,39 @@ function player() {
|
||||||
|
|
||||||
function settings(main) {
|
function settings(main) {
|
||||||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
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 flushCard = h('div', { class: 'card', style: 'margin-top:16px' });
|
||||||
|
const whereCard = h('div', { class: 'card', style: 'margin-bottom:16px' });
|
||||||
|
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Where your drives live, and s3d.yml for s3d on this computer. s3d reads it at start, so a restart applies the changes.'))), whereCard, card, flushCard);
|
||||||
|
hostedCard(whereCard);
|
||||||
|
// s3d.yml and the leftovers setting are for s3d on this computer; on Silent
|
||||||
|
// Mode the server manages both (leftovers always go to Sia).
|
||||||
|
if (isHosted()) {
|
||||||
|
put(card, h('h2', {}, 's3d on this computer'), h('p', { class: 'small muted' }, 'Your drives run on Silent Mode, so s3d on this computer is not used. Its settings come back when you move your drives back here.'));
|
||||||
|
flushCard.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept by Pithos itself (pithos-prefs.json), applies at once, no restart.
|
||||||
|
(async () => {
|
||||||
|
let af;
|
||||||
|
try { af = await api('GET', '/api/autoflush'); } catch (e) { put(flushCard, h('p', { class: 'error' }, e.message)); return; }
|
||||||
|
const on = h('input', { type: 'checkbox', checked: af.enabled });
|
||||||
|
const minutes = h('input', { type: 'number', min: 1, max: 1440, value: af.minutes, style: 'width:6em', disabled: !af.enabled });
|
||||||
|
on.addEventListener('change', () => { minutes.disabled = !on.checked; });
|
||||||
|
put(flushCard,
|
||||||
|
h('h2', {}, 'Leftover uploads'),
|
||||||
|
h('p', { class: 'small muted' }, 's3d packs uploads into blocks of about 40 MB before sending them to Sia, so a few small files can wait in the local buffer indefinitely. Pithos can upload them for you once uploading has stopped.'),
|
||||||
|
h('label', { class: 'check' }, on, 'Upload leftovers automatically'),
|
||||||
|
h('label', { class: 'field' }, h('span', {}, 'after this many minutes with no new uploads'), minutes,
|
||||||
|
h('small', {}, 'Partly filled blocks use a little more Sia space than full ones. Pithos must be open for this to run.')),
|
||||||
|
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: saveFlush }, 'Save')));
|
||||||
|
async function saveFlush() {
|
||||||
|
try {
|
||||||
|
await api('PUT', '/api/autoflush', { enabled: on.checked, minutes: Number(minutes.value) || 10 });
|
||||||
|
toast(on.checked ? 'Leftovers will upload automatically' : 'Automatic upload is off');
|
||||||
|
} catch (e) { fail(e); }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
['Network', [
|
['Network', [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue