@julesl23/s5js
Version:
Enhanced TypeScript SDK for S5 decentralized storage with path-based API, media processing, and directory utilities
197 lines • 8.51 kB
JavaScript
import { bytesToUtf8, utf8ToBytes } from "@noble/ciphers/utils";
import { portalAccountLogin } from "../account/login.js";
import { portalAccountRegister } from "../account/register.js";
import { S5Portal } from "../account/portal.js";
import { BlobIdentifier } from "../identifier/blob.js";
import { base64UrlNoPaddingDecode, base64UrlNoPaddingEncode } from "../util/base64.js";
import { TrustedHiddenDBProvider } from "./hidden_db.js";
import { MULTIHASH_BLAKE3 } from "../constants.js";
import { concatBytes } from "@noble/hashes/utils";
const portalUploadEndpoint = 'upload';
const hiddenStorageServiceAccountsPath = 'accounts.json';
export class S5APIWithIdentity {
node;
identity;
authStore;
accountsRes;
accounts = {};
accountConfigs = {};
hiddenDB;
httpClientCache = null;
constructor(node, identity, authStore) {
this.node = node;
this.identity = identity;
this.authStore = authStore;
this.hiddenDB = new TrustedHiddenDBProvider(identity.hiddenDBKey, this);
}
/**
* Get HTTP client with environment-specific fetch and FormData.
* Uses undici in Node.js (proven to work) and native APIs in browser.
*/
async getHttpClient() {
if (this.httpClientCache)
return this.httpClientCache;
if (typeof window === 'undefined') {
// Node.js environment - use undici for compatibility with S5 portals
const undici = await import('undici');
this.httpClientCache = {
fetch: undici.fetch,
FormData: undici.FormData
};
}
else {
// Browser environment - use native web APIs
this.httpClientCache = {
fetch: globalThis.fetch,
FormData: globalThis.FormData
};
}
return this.httpClientCache;
}
async ensureInitialized() {
await this.node.ensureInitialized();
await this.initStorageServices();
}
async initStorageServices() {
const res = await this.hiddenDB.getJSON(hiddenStorageServiceAccountsPath);
this.accountsRes = res;
this.accounts = res.data ?? {
'accounts': {},
'active': [],
'uploadOrder': { 'default': [] }
};
for (const id of this.accounts['active']) {
if (!Object.hasOwn(this.accountConfigs, id)) {
await this.setupAccount(id);
}
}
}
async setupAccount(id) {
console.info(`[account] setup ${id}`);
const config = this.accounts['accounts'][id];
const uri = new URL(config['url']);
const authTokenKey = this.getAuthTokenKey(id);
if (!(await this.authStore.contains(authTokenKey))) {
// TODO Check if the auth token is valid/expired
try {
const portal = new S5Portal(uri.protocol.replace(':', ''), uri.hostname + (uri.port ? `:${uri.port}` : ''), {});
const seed = base64UrlNoPaddingDecode(config['seed']);
const authToken = await portalAccountLogin(portal, this.identity, seed, 's5.js', this.node.crypto);
await this.authStore.put(authTokenKey, utf8ToBytes(authToken));
}
catch (e) {
console.error(e);
}
}
const authToken = bytesToUtf8((await this.authStore.get(authTokenKey)));
const portalConfig = new S5Portal(uri.protocol.replace(':', ''), uri.hostname + (uri.port ? `:${uri.port}` : ''), {
'Authorization': `Bearer ${authToken}`,
});
this.accountConfigs[id] = portalConfig;
// TODO this.connectToPortalNodes(portalConfig);
}
async saveStorageServices() {
await this.hiddenDB.setJSON(hiddenStorageServiceAccountsPath, this.accounts, (this.accountsRes?.revision ?? 0) + 1);
}
async registerAccount(url, inviteCode) {
await this.initStorageServices();
const uri = new URL(url);
for (const id of Object.keys(this.accountConfigs)) {
if (id.startsWith(`${uri.host}:`)) {
throw new Error('User already has an account on this service!');
}
}
const portalConfig = new S5Portal(uri.protocol.replace(':', ''), uri.hostname + (uri.port ? `:${uri.port}` : ''), {});
const seed = this.crypto.generateSecureRandomBytes(32);
const authToken = await portalAccountRegister(portalConfig, this.identity, seed, 's5.js', this.node.crypto, inviteCode);
const id = `${uri.host}:${base64UrlNoPaddingEncode(seed.slice(0, 12))}`;
this.accounts['accounts'][id] = {
'url': `${uri.protocol}//${uri.host}`,
'seed': base64UrlNoPaddingEncode(seed),
'createdAt': new Date().toISOString(),
};
this.accounts['active'].push(id);
this.accounts['uploadOrder']['default'].push(id);
await this.authStore.put(this.getAuthTokenKey(id), new TextEncoder().encode(authToken));
await this.setupAccount(id);
await this.saveStorageServices();
// TODO updateQuota();
}
getAuthTokenKey(id) {
return utf8ToBytes(`identity_main_account_${id}_auth_token`);
}
async uploadBlob(blob) {
if (Object.keys(this.accountConfigs).length == 0) {
throw new Error("No portals available for upload");
}
const blake3Hash = await this.crypto.hashBlake3Blob(blob);
const expectedBlobIdentifier = new BlobIdentifier(concatBytes(new Uint8Array([MULTIHASH_BLAKE3]), blake3Hash), blob.size);
const portals = Object.values(this.accountConfigs);
for (const portal of portals.concat(portals, portals)) {
try {
// Get environment-appropriate HTTP client
const { fetch, FormData } = await this.getHttpClient();
// Use File directly from blob data
const arrayBuffer = await blob.arrayBuffer();
const file = new File([arrayBuffer], 'file', { type: 'application/octet-stream' });
// Use environment-specific FormData (undici in Node.js, native in browser)
const formData = new FormData();
formData.append('file', file);
const uploadUrl = portal.apiURL(portalUploadEndpoint);
const authHeader = portal.headers['Authorization'] || portal.headers['authorization'] || '';
// Use environment-specific fetch (undici in Node.js, native in browser)
const res = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': authHeader
},
body: formData,
});
if (!res.ok) {
const errorText = await res.text();
console.log(`[upload] Failed with status ${res.status}, response: ${errorText}`);
throw new Error(`HTTP ${res.status}: ${errorText}`);
}
const responseData = await res.json();
const bid = BlobIdentifier.decode(responseData.cid);
if (bid.toHex() !== expectedBlobIdentifier.toHex()) {
throw `Integrity check for blob upload to ${portal.host} failed (got ${bid}, expected ${expectedBlobIdentifier})`;
}
return expectedBlobIdentifier;
}
catch (e) {
console.error(`Failed to upload blob to ${portal.host}`, e);
}
}
throw new Error("Failed to upload blob with 3 tries for each available portal");
}
pinHash(hash) {
throw new Error("Method not implemented.");
}
async unpinHash(hash) {
// TODO Implement method
return;
}
downloadBlobAsBytes(hash) {
return this.node.downloadBlobAsBytes(hash);
}
registryGet(pk) {
return this.node.registryGet(pk);
}
registryListen(pk) {
return this.node.registryListen(pk);
}
registrySet(entry) {
return this.node.registrySet(entry);
}
streamSubscribe(pk, afterTimestamp, beforeTimestamp) {
return this.node.streamSubscribe(pk, afterTimestamp, beforeTimestamp);
}
streamPublish(msg) {
return this.node.streamPublish(msg);
}
get crypto() {
return this.node.crypto;
}
}
//# sourceMappingURL=api.js.map