UNPKG

@julesl23/s5js

Version:

Enhanced TypeScript SDK for S5 decentralized storage with path-based API, media processing, and directory utilities

197 lines 8.51 kB
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