UNPKG

blockstack

Version:

The Blockstack Javascript library for authentication, identity, and storage.

1,014 lines (951 loc) 36.6 kB
import { getFullReadUrl, connectToGaiaHub, uploadToGaiaHub, getBucketUrl, BLOCKSTACK_GAIA_HUB_LABEL, GaiaHubConfig, deleteFromGaiaHub } from './hub' import { encryptECIES, decryptECIES, signECDSA, verifyECDSA, eciesGetJsonStringLength, SignedCipherObject, CipherTextEncoding } from '../encryption/ec' import { getPublicKeyFromPrivate, publicKeyToAddress } from '../keys' import { lookupProfile } from '../profiles/profileLookup' import { InvalidStateError, SignatureVerificationError, DoesNotExist, PayloadTooLargeError, GaiaHubError } from '../errors' import { UserSession } from '../auth/userSession' import { NAME_LOOKUP_PATH } from '../auth/authConstants' import { getGlobalObject, getBlockstackErrorFromResponse, megabytesToBytes } from '../utils' import { fetchPrivate } from '../fetchUtil' export interface EncryptionOptions { /** * If set to `true` the data is signed using ECDSA on SHA256 hashes with the user's * app private key. If a string is specified, it is used as the private key instead * of the user's app private key. * @default false */ sign?: boolean | string; /** * String encoding format for the cipherText buffer. * Currently defaults to 'hex' for legacy backwards-compatibility. * Only used if the `encrypt` option is also used. * Note: in the future this should default to 'base64' for the significant * file size reduction. */ cipherTextEncoding?: CipherTextEncoding; /** * Specifies if the original unencrypted content is a ASCII or UTF-8 string. * For example stringified JSON. * If true, then when the ciphertext is decrypted, it will be returned as * a `string` type variable, otherwise will be returned as a Buffer. */ wasString?: boolean; } /** * Specify encryption options, and whether to sign the ciphertext. */ export interface EncryptContentOptions extends EncryptionOptions { /** * Encrypt the data with this key. * If not provided then the current user's app public key is used. */ publicKey?: string; } /** * Specify a valid MIME type, encryption options, and whether to sign the [[UserSession.putFile]]. */ export interface PutFileOptions extends EncryptionOptions { /** * Specifies the Content-Type header for unencrypted data. * If the `encrypt` is enabled, this option is ignored, and the * Content-Type header is set to `application/json` for the ciphertext * JSON envelope. */ contentType?: string; /** * Encrypt the data with the app public key. * If a string is specified, it is used as the public key. * If the boolean `true` is specified then the current user's app public key is used. * @default true */ encrypt?: boolean | string; /** * Ignore etag for concurrency control and force file to be written. */ dangerouslyIgnoreEtag?: boolean; } const SIGNATURE_FILE_SUFFIX = '.sig' /** * Fetch the public read URL of a user file for the specified app. * @param {String} path - the path to the file to read * @param {String} username - The Blockstack ID of the user to look up * @param {String} appOrigin - The app origin * @param {String} [zoneFileLookupURL=null] - The URL * to use for zonefile lookup. If falsey, this will use the * blockstack.js's [[getNameInfo]] function instead. * @return {Promise<string>} that resolves to the public read URL of the file * or rejects with an error */ export async function getUserAppFileUrl( path: string, username: string, appOrigin: string, zoneFileLookupURL?: string ): Promise<string|null> { const profile = await lookupProfile(username, zoneFileLookupURL) let bucketUrl: string = null if (profile.hasOwnProperty('apps')) { if (profile.apps.hasOwnProperty(appOrigin)) { const url = profile.apps[appOrigin] const bucket = url.replace(/\/?(\?|#|$)/, '/$1') bucketUrl = `${bucket}${path}` } } return bucketUrl } /** * Encrypts the data provided with the app public key. * @param {String|Buffer} content - data to encrypt * @param {Object} [options=null] - options object * @param {String} options.publicKey - the hex string of the ECDSA public * key to use for encryption. If not provided, will use user's appPublicKey. * @return {String} Stringified ciphertext object */ export async function encryptContent( caller: UserSession, content: string | Buffer, options?: EncryptContentOptions ): Promise<string> { const opts = Object.assign({}, options) let privateKey: string if (!opts.publicKey) { privateKey = caller.loadUserData().appPrivateKey opts.publicKey = getPublicKeyFromPrivate(privateKey) } let wasString: boolean if (typeof opts.wasString === 'boolean') { wasString = opts.wasString } else { wasString = typeof content === 'string' } const contentBuffer = typeof content === 'string' ? Buffer.from(content) : content const cipherObject = await encryptECIES(opts.publicKey, contentBuffer, wasString, opts.cipherTextEncoding) let cipherPayload = JSON.stringify(cipherObject) if (opts.sign) { if (typeof opts.sign === 'string') { privateKey = opts.sign } else if (!privateKey) { privateKey = caller.loadUserData().appPrivateKey } const signatureObject = signECDSA(privateKey, cipherPayload) const signedCipherObject: SignedCipherObject = { signature: signatureObject.signature, publicKey: signatureObject.publicKey, cipherText: cipherPayload } cipherPayload = JSON.stringify(signedCipherObject) } return cipherPayload } /** * Decrypts data encrypted with `encryptContent` with the * transit private key. * @param {String|Buffer} content - encrypted content. * @param {Object} [options=null] - options object * @param {String} options.privateKey - the hex string of the ECDSA private * key to use for decryption. If not provided, will use user's appPrivateKey. * @return {String|Buffer} decrypted content. */ export function decryptContent( caller: UserSession, content: string, options?: { privateKey?: string }, ): Promise<string | Buffer> { const opts = Object.assign({}, options) if (!opts.privateKey) { opts.privateKey = caller.loadUserData().appPrivateKey } try { const cipherObject = JSON.parse(content) return decryptECIES(opts.privateKey, cipherObject) } catch (err) { if (err instanceof SyntaxError) { throw new Error('Failed to parse encrypted content JSON. The content may not ' + 'be encrypted. If using getFile, try passing { decrypt: false }.') } else { throw err } } } /* Get the gaia address used for servicing multiplayer reads for the given * (username, app) pair. * @private * @ignore */ async function getGaiaAddress( caller: UserSession, app: string, username?: string, zoneFileLookupURL?: string, ): Promise<string> { const opts = normalizeOptions(caller, { app, username, zoneFileLookupURL }) let fileUrl: string if (username) { fileUrl = await getUserAppFileUrl('/', opts.username, opts.app, opts.zoneFileLookupURL) } else { const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection() fileUrl = await getFullReadUrl('/', gaiaHubConfig) } const matches = fileUrl.match(/([13][a-km-zA-HJ-NP-Z0-9]{26,35})/) if (!matches) { throw new Error('Failed to parse gaia address') } return matches[matches.length - 1] } /** * @param {Object} [options=null] - options object * @param {String} options.username - the Blockstack ID to lookup for multi-player storage * @param {String} options.app - the app to lookup for multi-player storage - * defaults to current origin * * @ignore */ function normalizeOptions<T>( caller: UserSession, options?: { app?: string, username?: string, zoneFileLookupURL?: string } & T, ) { const opts = Object.assign({}, options) if (opts.username) { if (!opts.app) { if (!caller.appConfig) { throw new InvalidStateError('Missing AppConfig') } opts.app = caller.appConfig.appDomain } if (!opts.zoneFileLookupURL) { if (!caller.appConfig) { throw new InvalidStateError('Missing AppConfig') } if (!caller.store) { throw new InvalidStateError('Missing store UserSession') } const sessionData = caller.store.getSessionData() // Use the user specified coreNode if available, otherwise use the app specified coreNode. const configuredCoreNode = sessionData.userData.coreNode || caller.appConfig.coreNode if (configuredCoreNode) { opts.zoneFileLookupURL = `${configuredCoreNode}${NAME_LOOKUP_PATH}` } } } return opts } /** * * @param {String} path - the path to the file to read * @returns {Promise<string>} that resolves to the URL or rejects with an error */ export async function getFileUrl( caller: UserSession, path: string, options?: GetFileUrlOptions ): Promise<string> { const opts = normalizeOptions(caller, options) let readUrl: string if (opts.username) { readUrl = await getUserAppFileUrl(path, opts.username, opts.app, opts.zoneFileLookupURL) } else { const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection() readUrl = await getFullReadUrl(path, gaiaHubConfig) } if (!readUrl) { throw new Error('Missing readURL') } else { return readUrl } } /* Handle fetching the contents from a given path. Handles both * multi-player reads and reads from own storage. * @private * @ignore */ async function getFileContents(caller: UserSession, path: string, app: string, username: string | undefined, zoneFileLookupURL: string | undefined, forceText: boolean): Promise<string | ArrayBuffer | null> { const opts = { app, username, zoneFileLookupURL } const readUrl = await getFileUrl(caller, path, opts) const response = await fetchPrivate(readUrl) if (!response.ok) { throw await getBlockstackErrorFromResponse(response, `getFile ${path} failed.`, null) } let contentType = response.headers.get('Content-Type') if (typeof contentType === 'string') { contentType = contentType.toLowerCase() } const etag = response.headers.get('ETag') if (etag) { const sessionData = caller.store.getSessionData() sessionData.etags[path] = etag caller.store.setSessionData(sessionData) } if (forceText || contentType === null || contentType.startsWith('text') || contentType.startsWith('application/json')) { return response.text() } else { return response.arrayBuffer() } } /* Handle fetching an unencrypted file, its associated signature * and then validate it. Handles both multi-player reads and reads * from own storage. * @private * @ignore */ async function getFileSignedUnencrypted(caller: UserSession, path: string, opt: GetFileOptions) { // future optimization note: // in the case of _multi-player_ reads, this does a lot of excess // profile lookups to figure out where to read files // do browsers cache all these requests if Content-Cache is set? const sigPath = `${path}${SIGNATURE_FILE_SUFFIX}` try { const [fileContents, signatureContents, gaiaAddress] = await Promise.all([ getFileContents(caller, path, opt.app, opt.username, opt.zoneFileLookupURL, false), getFileContents(caller, sigPath, opt.app, opt.username, opt.zoneFileLookupURL, true), getGaiaAddress(caller, opt.app, opt.username, opt.zoneFileLookupURL) ]) if (!fileContents) { return fileContents } if (!gaiaAddress) { throw new SignatureVerificationError('Failed to get gaia address for verification of: ' + `${path}`) } if (!signatureContents || typeof signatureContents !== 'string') { throw new SignatureVerificationError('Failed to obtain signature for file: ' + `${path} -- looked in ${path}${SIGNATURE_FILE_SUFFIX}`) } let signature let publicKey try { const sigObject = JSON.parse(signatureContents) signature = sigObject.signature publicKey = sigObject.publicKey } catch (err) { if (err instanceof SyntaxError) { throw new Error('Failed to parse signature content JSON ' + `(path: ${path}${SIGNATURE_FILE_SUFFIX})` + ' The content may be corrupted.') } else { throw err } } const signerAddress = publicKeyToAddress(publicKey) if (gaiaAddress !== signerAddress) { throw new SignatureVerificationError(`Signer pubkey address (${signerAddress}) doesn't` + ` match gaia address (${gaiaAddress})`) } else if (!verifyECDSA(fileContents, publicKey, signature)) { throw new SignatureVerificationError( 'Contents do not match ECDSA signature: ' + `path: ${path}, signature: ${path}${SIGNATURE_FILE_SUFFIX}` ) } else { return fileContents } } catch (err) { // For missing .sig files, throw `SignatureVerificationError` instead of `DoesNotExist` error. if (err instanceof DoesNotExist && err.message.indexOf(sigPath) >= 0) { throw new SignatureVerificationError('Failed to obtain signature for file: ' + `${path} -- looked in ${path}${SIGNATURE_FILE_SUFFIX}`) } else { throw err } } } /* Handle signature verification and decryption for contents which are * expected to be signed and encrypted. This works for single and * multiplayer reads. In the case of multiplayer reads, it uses the * gaia address for verification of the claimed public key. * @private * @ignore */ async function handleSignedEncryptedContents(caller: UserSession, path: string, storedContents: string, app: string, privateKey?: string, username?: string, zoneFileLookupURL?: string ): Promise<string | Buffer> { const appPrivateKey = privateKey || caller.loadUserData().appPrivateKey const appPublicKey = getPublicKeyFromPrivate(appPrivateKey) let address: string if (username) { address = await getGaiaAddress(caller, app, username, zoneFileLookupURL) } else { address = publicKeyToAddress(appPublicKey) } if (!address) { throw new SignatureVerificationError('Failed to get gaia address for verification of: ' + `${path}`) } let sigObject try { sigObject = JSON.parse(storedContents) } catch (err) { if (err instanceof SyntaxError) { throw new Error('Failed to parse encrypted, signed content JSON. The content may not ' + 'be encrypted. If using getFile, try passing' + ' { verify: false, decrypt: false }.') } else { throw err } } const signature = sigObject.signature const signerPublicKey = sigObject.publicKey const cipherText = sigObject.cipherText const signerAddress = publicKeyToAddress(signerPublicKey) if (!signerPublicKey || !cipherText || !signature) { throw new SignatureVerificationError( 'Failed to get signature verification data from file:' + ` ${path}` ) } else if (signerAddress !== address) { throw new SignatureVerificationError(`Signer pubkey address (${signerAddress}) doesn't` + ` match gaia address (${address})`) } else if (!verifyECDSA(cipherText, signerPublicKey, signature)) { throw new SignatureVerificationError('Contents do not match ECDSA signature in file:' + ` ${path}`) } else if (typeof (privateKey) === 'string') { const decryptOpt = { privateKey } return caller.decryptContent(cipherText, decryptOpt) } else { return caller.decryptContent(cipherText) } } export interface GetFileUrlOptions { /** * The Blockstack ID to lookup for multi-player storage. * If not specified, the currently signed in username is used. */ username?: string; /** * The app to lookup for multi-player storage - defaults to current origin. * @default `window.location.origin` * Only if available in the executing environment, otherwise `undefined`. */ app?: string; /** * The URL to use for zonefile lookup. If falsey, this will use * the blockstack.js's [[getNameInfo]] function instead. */ zoneFileLookupURL?: string; } /** * Used to pass options to [[UserSession.getFile]] */ export interface GetFileOptions extends GetFileUrlOptions { /** * Try to decrypt the data with the app private key. * If a string is specified, it is used as the private key. * @default true */ decrypt?: boolean | string; /** * Whether the content should be verified, only to be used * when [[UserSession.putFile]] was set to `sign = true`. * @default false */ verify?: boolean; } /** * Retrieves the specified file from the app's data store. * @param {String} path - the path to the file to read * @returns {Promise} that resolves to the raw data in the file * or rejects with an error */ export async function getFile( caller: UserSession, path: string, options?: GetFileOptions, ) { const defaults: GetFileOptions = { decrypt: true, verify: false, username: null, app: getGlobalObject('location', { returnEmptyObject: true }).origin, zoneFileLookupURL: null } const opt = Object.assign({}, defaults, options) // in the case of signature verification, but no // encryption expected, need to fetch _two_ files. if (opt.verify && !opt.decrypt) { return getFileSignedUnencrypted(caller, path, opt) } const storedContents = await getFileContents(caller, path, opt.app, opt.username, opt.zoneFileLookupURL, !!opt.decrypt) if (storedContents === null) { return storedContents } else if (opt.decrypt && !opt.verify) { if (typeof storedContents !== 'string') { throw new Error('Expected to get back a string for the cipherText') } if (typeof (opt.decrypt) === 'string') { const decryptOpt = { privateKey: opt.decrypt } return caller.decryptContent(storedContents, decryptOpt) } else { return caller.decryptContent(storedContents) } } else if (opt.decrypt && opt.verify) { if (typeof storedContents !== 'string') { throw new Error('Expected to get back a string for the cipherText') } let decryptionKey if (typeof (opt.decrypt) === 'string') { decryptionKey = opt.decrypt } return handleSignedEncryptedContents(caller, path, storedContents, opt.app, decryptionKey, opt.username, opt.zoneFileLookupURL) } else if (!opt.verify && !opt.decrypt) { return storedContents } else { throw new Error('Should be unreachable.') } } /** @ignore */ type PutFileContent = string | Buffer | ArrayBufferView | ArrayBufferLike | Blob /** @ignore */ class FileContentLoader { readonly content: Buffer | Blob readonly wasString: boolean readonly contentType: string readonly contentByteLength: number private loadedData?: Promise<Buffer> static readonly supportedTypesMsg = 'Supported types are: `string` (to be UTF8 encoded), ' + '`Buffer`, `Blob`, `File`, `ArrayBuffer`, `UInt8Array` or any other typed array buffer. ' constructor(content: PutFileContent, contentType: string) { this.wasString = typeof content === 'string' this.content = FileContentLoader.normalizeContentDataType(content, contentType) this.contentType = contentType || this.detectContentType() this.contentByteLength = this.detectContentLength() } private static normalizeContentDataType(content: PutFileContent, contentType: string): Buffer | Blob { try { if (typeof content === 'string') { // If a charset is specified it must be either utf8 or ascii, otherwise the encoded content // length cannot be reliably detected. If no charset specified it will be treated as utf8. const charset = (contentType || '').toLowerCase().replace('-', '') if (charset.includes('charset') && !charset.includes('charset=utf8') && !charset.includes('charset=ascii')) { throw new Error(`Unable to determine byte length with charset: ${contentType}`) } if (typeof TextEncoder !== 'undefined') { const encodedString = new TextEncoder().encode(content) return Buffer.from(encodedString.buffer) } return Buffer.from(content) } else if (Buffer.isBuffer(content)) { return content } else if (ArrayBuffer.isView(content)) { return Buffer.from(content.buffer, content.byteOffset, content.byteLength) } else if (typeof Blob !== 'undefined' && content instanceof Blob) { return content } else if (typeof ArrayBuffer !== 'undefined' && content instanceof ArrayBuffer) { return Buffer.from(content) } else if (Array.isArray(content)) { // Provided with a regular number `Array` -- this is either an (old) method // of representing an octet array, or a dev error. Perform basic check for octet array. if (content.length > 0 && (!Number.isInteger(content[0]) || content[0] < 0 || content[0] > 255)) { throw new Error(`Unexpected array values provided as file data: value "${content[0]}" at index 0 is not an octet number. ${this.supportedTypesMsg}`) } return Buffer.from(content) } else { const typeName = Object.prototype.toString.call(content) throw new Error(`Unexpected type provided as file data: ${typeName}. ${this.supportedTypesMsg}`) } } catch (error) { console.error(error) throw new Error(`Error processing data: ${error}`) } } private detectContentType(): string { if (this.wasString) { return 'text/plain; charset=utf-8' } else if (typeof Blob !== 'undefined' && this.content instanceof Blob && this.content.type) { return this.content.type } else { return 'application/octet-stream' } } private detectContentLength(): number { if (ArrayBuffer.isView(this.content) || Buffer.isBuffer(this.content)) { return this.content.byteLength } else if (typeof Blob !== 'undefined' && this.content instanceof Blob) { return this.content.size } const typeName = Object.prototype.toString.call(this.content) const error = new Error(`Unexpected type "${typeName}" while detecting content length`) console.error(error) throw error } private async loadContent(): Promise<Buffer> { try { if (Buffer.isBuffer(this.content)) { return this.content } else if (ArrayBuffer.isView(this.content)) { return Buffer.from(this.content.buffer, this.content.byteOffset, this.content.byteLength) } else if (typeof Blob !== 'undefined' && this.content instanceof Blob) { const reader = new FileReader() const readPromise = new Promise<Buffer>((resolve, reject) => { reader.onerror = (err) => { reject(err) } reader.onload = () => { const arrayBuffer = reader.result as ArrayBuffer resolve(Buffer.from(arrayBuffer)) } reader.readAsArrayBuffer(this.content as Blob) }) const result = await readPromise return result } else { const typeName = Object.prototype.toString.call(this.content) throw new Error(`Unexpected type ${typeName}`) } } catch (error) { console.error(error) const loadContentError = new Error(`Error loading content: ${error}`) console.error(loadContentError) throw loadContentError } } load(): Promise<Buffer | string> { if (this.loadedData === undefined) { this.loadedData = this.loadContent() } return this.loadedData } } /** * Determines if a gaia error response is possible to recover from * by refreshing the gaiaHubConfig, and retrying the request. */ function isRecoverableGaiaError(error: GaiaHubError): boolean { if (!error || !error.hubError || !error.hubError.statusCode) { return false } const statusCode = error.hubError.statusCode // 401 Unauthorized: possible expired, but renewable auth token. if (statusCode === 401) { return true } // 409 Conflict: possible concurrent writes to a file. if (statusCode === 409) { return true } // 500s: possible server-side transient error if (statusCode >= 500 && statusCode <= 599) { return true } return false } /** * Stores the data provided in the app's data store to to the file specified. * @param {UserSession} caller - internal use only: the usersession * @param {String} path - the path to store the data in * @param {String|Buffer|ArrayBufferView|Blob} content - the data to store in the file * @param {PutFileOptions} options - the putfile options * @return {Promise} that resolves if the operation succeed and rejects * if it failed */ export async function putFile( caller: UserSession, path: string, content: string | Buffer | ArrayBufferView | Blob, options?: PutFileOptions, ): Promise<string> { const defaults: PutFileOptions = { encrypt: true, sign: false, cipherTextEncoding: 'hex', dangerouslyIgnoreEtag: false } const opt = Object.assign({}, defaults, options) const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection() const maxUploadBytes = megabytesToBytes(gaiaHubConfig.max_file_upload_size_megabytes) const hasMaxUpload = maxUploadBytes > 0 const contentLoader = new FileContentLoader(content, opt.contentType) let contentType = contentLoader.contentType // When not encrypting the content length can be checked immediately. if (!opt.encrypt && hasMaxUpload && contentLoader.contentByteLength > maxUploadBytes) { const sizeErrMsg = `The max file upload size for this hub is ${maxUploadBytes} bytes, the given content is ${contentLoader.contentByteLength} bytes` const sizeErr = new PayloadTooLargeError(sizeErrMsg, null, maxUploadBytes) console.error(sizeErr) throw sizeErr } // When encrypting, the content length must be calculated. Certain types like `Blob`s must // be loaded into memory. if (opt.encrypt && hasMaxUpload) { const encryptedSize = eciesGetJsonStringLength({ contentLength: contentLoader.contentByteLength, wasString: contentLoader.wasString, sign: !!opt.sign, cipherTextEncoding: opt.cipherTextEncoding }) if (encryptedSize > maxUploadBytes) { const sizeErrMsg = `The max file upload size for this hub is ${maxUploadBytes} bytes, the given content is ${encryptedSize} bytes after encryption` const sizeErr = new PayloadTooLargeError(sizeErrMsg, null, maxUploadBytes) console.error(sizeErr) throw sizeErr } } let etag: string let newFile = true const sessionData = caller.store.getSessionData(); if (!opt.dangerouslyIgnoreEtag) { if (sessionData.etags[path]) { newFile = false etag = sessionData.etags[path] } } let uploadFn: (hubConfig: GaiaHubConfig) => Promise<string> // In the case of signing, but *not* encrypting, we perform two uploads. if (!opt.encrypt && opt.sign) { const contentData = await contentLoader.load() let privateKey: string if (typeof opt.sign === 'string') { privateKey = opt.sign } else { privateKey = caller.loadUserData().appPrivateKey } const signatureObject = signECDSA(privateKey, contentData) const signatureContent = JSON.stringify(signatureObject) uploadFn = async (hubConfig: GaiaHubConfig) => { const writeResponse = (await Promise.all([ uploadToGaiaHub(path, contentData, hubConfig, contentType, newFile, etag, opt.dangerouslyIgnoreEtag), uploadToGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, signatureContent, hubConfig, 'application/json') ]))[0] if (writeResponse.etag) { sessionData.etags[path] = writeResponse.etag; caller.store.setSessionData(sessionData); } return writeResponse.publicURL } } else { // In all other cases, we only need one upload. let contentForUpload: string | Buffer | Blob if (!opt.encrypt && !opt.sign) { // If content does not need encrypted or signed, it can be passed directly // to the fetch request without loading into memory. contentForUpload = contentLoader.content } else { // Use the `encrypt` key, otherwise the `sign` key, if neither are specified // then use the current user's app public key. let publicKey: string if (typeof opt.encrypt === 'string') { publicKey = opt.encrypt } else if (typeof opt.sign === 'string') { publicKey = getPublicKeyFromPrivate(opt.sign) } else { publicKey = getPublicKeyFromPrivate(caller.loadUserData().appPrivateKey) } const contentData = await contentLoader.load() contentForUpload = await encryptContent(caller, contentData, { publicKey, wasString: contentLoader.wasString, cipherTextEncoding: opt.cipherTextEncoding, sign: opt.sign }) contentType = 'application/json' } uploadFn = async (hubConfig: GaiaHubConfig) => { const writeResponse = await uploadToGaiaHub( path, contentForUpload, hubConfig, contentType, newFile, etag, opt.dangerouslyIgnoreEtag ) if (writeResponse.etag) { sessionData.etags[path] = writeResponse.etag; caller.store.setSessionData(sessionData); } return writeResponse.publicURL } } try { return await uploadFn(gaiaHubConfig) } catch (error) { // If the upload fails on first attempt, it could be due to a recoverable // error which may succeed by refreshing the config and retrying. if (isRecoverableGaiaError(error)) { console.error(error) console.error('Possible recoverable error during Gaia upload, retrying...') const freshHubConfig = await caller.setLocalGaiaHubConnection() return await uploadFn(freshHubConfig) } else { throw error } } } /** * Deletes the specified file from the app's data store. * @param path - The path to the file to delete. * @param options - Optional options object. * @param options.wasSigned - Set to true if the file was originally signed * in order for the corresponding signature file to also be deleted. * @returns Resolves when the file has been removed or rejects with an error. */ export async function deleteFile( caller: UserSession, path: string, options?: { wasSigned?: boolean; } ) { const gaiaHubConfig = await caller.getOrSetLocalGaiaHubConnection() const opts = Object.assign({}, options) const sessionData = caller.store.getSessionData(); if (opts.wasSigned) { // If signed, delete both the content file and the .sig file try { await deleteFromGaiaHub(path, gaiaHubConfig) await deleteFromGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, gaiaHubConfig) delete sessionData.etags[path]; caller.store.setSessionData(sessionData); } catch (error) { const freshHubConfig = await caller.setLocalGaiaHubConnection() await deleteFromGaiaHub(path, freshHubConfig) await deleteFromGaiaHub(`${path}${SIGNATURE_FILE_SUFFIX}`, gaiaHubConfig) delete sessionData.etags[path]; caller.store.setSessionData(sessionData); } } else { try { await deleteFromGaiaHub(path, gaiaHubConfig) delete sessionData.etags[path]; caller.store.setSessionData(sessionData); } catch (error) { const freshHubConfig = await caller.setLocalGaiaHubConnection() await deleteFromGaiaHub(path, freshHubConfig) delete sessionData.etags[path]; caller.store.setSessionData(sessionData); } } } /** * Get the app storage bucket URL * @param {String} gaiaHubUrl - the gaia hub URL * @param {String} appPrivateKey - the app private key used to generate the app address * @returns {Promise} That resolves to the URL of the app index file * or rejects if it fails */ export function getAppBucketUrl(gaiaHubUrl: string, appPrivateKey: string) { return getBucketUrl(gaiaHubUrl, appPrivateKey) } /** * Loop over the list of files in a Gaia hub, and run a callback on each entry. * Not meant to be called by external clients. * @param {GaiaHubConfig} hubConfig - the Gaia hub config * @param {String | null} page - the page ID * @param {number} callCount - the loop count * @param {number} fileCount - the number of files listed so far * @param {function} callback - the callback to invoke on each file. If it returns a falsey * value, then the loop stops. If it returns a truthy value, the loop continues. * @returns {Promise} that resolves to the number of files listed. * @private * @ignore */ async function listFilesLoop( caller: UserSession, hubConfig: GaiaHubConfig | null, page: string | null, callCount: number, fileCount: number, callback: (name: string) => boolean ): Promise<number> { if (callCount > 65536) { // this is ridiculously huge, and probably indicates // a faulty Gaia hub anyway (e.g. on that serves endless data) throw new Error('Too many entries to list') } hubConfig = hubConfig || await caller.getOrSetLocalGaiaHubConnection() let response: Response try { const pageRequest = JSON.stringify({ page }) const fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': `${pageRequest.length}`, Authorization: `bearer ${hubConfig.token}` }, body: pageRequest } response = await fetchPrivate(`${hubConfig.server}/list-files/${hubConfig.address}`, fetchOptions) if (!response.ok) { throw await getBlockstackErrorFromResponse(response, 'ListFiles failed.', hubConfig) } } catch (error) { // If error occurs on the first call, perform a gaia re-connection and retry. // Same logic as other gaia requests (putFile, getFile, etc). if (callCount === 0) { const freshHubConfig = await caller.setLocalGaiaHubConnection() return listFilesLoop(caller, freshHubConfig, page, callCount + 1, 0, callback) } throw error } const responseText = await response.text() const responseJSON = JSON.parse(responseText) const entries = responseJSON.entries const nextPage = responseJSON.page if (entries === null || entries === undefined) { // indicates a misbehaving Gaia hub or a misbehaving driver // (i.e. the data is malformed) throw new Error('Bad listFiles response: no entries') } let entriesLength = 0 for (let i = 0; i < entries.length; i++) { // An entry array can have null entries, signifying a filtered entry and that there may be // additional pages if (entries[i] !== null) { entriesLength++ const rc = callback(entries[i]) if (!rc) { // callback indicates that we're done return fileCount + i } } } if (nextPage && entries.length > 0) { // keep going -- have more entries return listFilesLoop( caller, hubConfig, nextPage, callCount + 1, fileCount + entriesLength, callback ) } else { // no more entries -- end of data return fileCount + entriesLength } } /** * List the set of files in this application's Gaia storage bucket. * @param {function} callback - a callback to invoke on each named file that * returns `true` to continue the listing operation or `false` to end it * @return {Promise} that resolves to the total number of listed files. * If the call is ended early by the callback, the last file is excluded. * If an error occurs the entire call is rejected. */ export function listFiles( caller: UserSession, callback: (name: string) => boolean ): Promise<number> { return listFilesLoop(caller, null, null, 0, 0, callback) } export { connectToGaiaHub, uploadToGaiaHub, BLOCKSTACK_GAIA_HUB_LABEL }