A server-sent file list could name paths outside the s3d folder (or the key file in it), and a failed move back deleted every listed path. Transfers now accept only s3d data paths, check all of them before writing, never overwrite, and clean up only what they wrote. A lost reply could lose or fork the drives: move-up and move-down now ask the server who holds them before touching the local copy, and once the server is told to release, the local copy is never removed (the release is retried). The gateway no longer lets one account route another's access key id, refuses . and .. in shared-key paths, strips presign parameters before re-signing, counts upload size on disk, stops s3d before it can be restarted mid-move, and claims sign-up proofs before checking them. The key file is never replaced when unreadable, a local edit removes the older encrypted copy, listing pages no longer repeat, and data hashes stream instead of loading whole files.
98 lines
4.6 KiB
JavaScript
98 lines
4.6 KiB
JavaScript
// 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);
|
|
}
|
|
}
|