UNPKG

@xrengine/server-core

Version:

Shared components for XREngine server

529 lines (450 loc) 15.1 kB
import * as k8s from '@kubernetes/client-node' import { create, IPFSHTTPClient } from 'ipfs-http-client' import * as net from 'net' import path from 'path' import * as stream from 'stream' import { concat as uint8ArrayConcat } from 'uint8arrays/concat' import { FileContentType } from '@xrengine/common/src/interfaces/FileContentType' import config from '../../appconfig' import { BlobStore, PutObjectParams, StorageListObjectInterface, StorageObjectInterface, StorageProviderInterface } from './storageprovider.interface' /** * Storage provide class to communicate with InterPlanetary File System (IPFS) using Mutable File System (MFS). */ export class IPFSStorage implements StorageProviderInterface { private _client: IPFSHTTPClient private _blobStore: IPFSBlobStore private _pathPrefix: string = '/' private _apiDomain: string /** * Domain address of cache. */ cacheDomain: string /** * Check if an object exists in the IPFS storage. * @param fileName Name of file in the storage. * @param directoryPath Directory of file in the storage. */ doesExist(fileName: string, directoryPath: string): Promise<boolean> { const filePath = path.join(this._pathPrefix, directoryPath, fileName) return this._client.files .stat(filePath) .then(() => true) .catch(() => false) } /** * Check if an object is directory or not. * @param fileName Name of file in the storage. * @param directoryPath Directory of file in the storage. */ isDirectory(fileName: string, directoryPath: string): Promise<boolean> { const filePath = path.join(this._pathPrefix, directoryPath, fileName) return this._client.files .stat(filePath) .then((res) => res.type === 'directory') .catch(() => false) } /** * Get the IPFS storage object. * @param key Key of object. */ async getObject(key: string): Promise<StorageObjectInterface> { const filePath = path.join(this._pathPrefix, key) const chunks: Uint8Array[] = [] for await (const chunk of this._client.files.read(filePath)) { chunks.push(chunk) } const chunksArray = uint8ArrayConcat(chunks) // const decodedData = new TextDecoder().decode(chunksArray).toString(); // console.log(decodedData) // const file=`https://ipfs.io/ipfs/${hash}`; // const req = await fetch(file, {method:'HEAD'}) // console.log(req.headers.get('content-type')) return { Body: Buffer.from(chunksArray), ContentType: 'application/octet-stream' } } /** * Get the object from cache, otherwise returns getObject. * @param key Key of object. */ async getCachedObject(key: string): Promise<StorageObjectInterface> { return this.getObject(key) } /** * Get the instance of IPFS storage provider. */ getProvider(): StorageProviderInterface { return this } /** * Get the signed url response of the storage object. * @param key Key of object. * @param expiresAfter The number of seconds for which signed policy should be valid. Defaults to 3600 (one hour). * @param conditions An array of conditions that must be met for certain providers. Not used in IPFS provider. */ async getSignedUrl(key: string, _expiresAfter: number, _conditions: any) { const url = await this._getUrl(key) return { fields: { Key: key }, url, local: false, cacheDomain: this.cacheDomain } } /** * Get the BlobStore object for IPFS storage. */ getStorage(): BlobStore { return this._blobStore } /** * Get a list of keys under a path. * @param prefix Path relative to root in order to list objects. * @param recursive If true it will list content from sub folders as well. * @param continuationToken It indicates that the list is being continued with a token. Not used in IPFS provider. */ async listObjects( prefix: string, recursive?: boolean, continuationToken?: string ): Promise<StorageListObjectInterface> { const filePath = path.join(this._pathPrefix, prefix) const exists = await this.doesExist(filePath, '') if (!exists) return { Contents: [] } const results: { Key: string }[] = [] if (recursive) { await this._parseMFSDirectory(filePath, results) } else { for await (const file of this._client.files.ls(filePath)) { const fullPath = path.join(filePath, file.name) results.push({ Key: fullPath }) } } return { Contents: results } } /** * Adds an object into the IPFS storage. * @param object Storage object to be added. * @param params Parameters of the add request. */ async putObject(object: StorageObjectInterface, params: PutObjectParams): Promise<boolean> { const filePath = path.join(this._pathPrefix, object.Key!) if (params.isDirectory) { if (!this.doesExist('', filePath)) { await this._client.files.mkdir(filePath, { parents: true }) return true } return false } await this._client.files.write(filePath, object.Body, { parents: true, create: true }) return true } /** * Delete resources in the IPFS storage. * @param keys List of keys. */ async deleteResources(keys: string[]) { const status: boolean[] = [] for (const key of keys) { try { const exists = await this.doesExist('', key) if (exists) { const filePath = path.join(this._pathPrefix, key) await this._client.files.rm(filePath, { recursive: true }) status.push(true) } else { status.push(true) } } catch { status.push(false) } } return status } /** * Invalidate items in the IPFS storage. * @param invalidationItems List of keys. */ async createInvalidation() { Promise.resolve() } /** * List all the files/folders in the directory. * @param folderName Name of folder in the storage. * @param recursive If true it will list content from sub folders as well. */ async listFolderContent(folderName: string, recursive?: boolean): Promise<FileContentType[]> { const filePath = path.join(this._pathPrefix, folderName) const results: FileContentType[] = [] if (recursive) { await this._parseMFSDirectoryAsType(filePath, results) } else { for await (const file of this._client.files.ls(filePath)) { const signedUrl = await this.getSignedUrl(file.cid.toString(), 3600, null) const res: FileContentType = { key: file.cid.toString(), name: file.name, type: file.type, url: signedUrl.url, size: this._formatBytes(file.size) } results.push(res) } } return results } /** * Move or copy object from one place to another in the IPFS storage. * @param oldName Name of the old object. * @param newName Name of the new object. * @param oldPath Path of the old object. * @param newPath Path of the new object. * @param isCopy If true it will create a copy of object. */ async moveObject(oldName: string, newName: string, oldPath: string, newPath: string, isCopy?: boolean): Promise<any> { const oldFilePath = path.join(this._pathPrefix, oldPath, oldName) const newFilePath = path.join(this._pathPrefix, newPath, newName) try { if (isCopy) { await this._client.files.cp(oldFilePath, newFilePath, { parents: true }) } else { await this._client.files.mv(oldFilePath, newFilePath, { parents: true }) } } catch (err) { return false } return true } /** * Initialize the IPFS storage. It port forwards the IPFS pod to expose its REST API for consumption. * @param podName Name of IPFS pod in cluster. */ async initialize(podName: string): Promise<void> { if (config.kubernetes.enabled) { const kc = new k8s.KubeConfig() kc.loadFromDefault() const forward = new k8s.PortForward(kc) this.cacheDomain = await this._forwardIPFS(podName, forward, 8080) this._apiDomain = await this._forwardIPFS(podName, forward, 5001) this._client = create({ url: `http://${this._apiDomain}` }) this._blobStore = new IPFSBlobStore(this._client) } } /** * Get the name of IPFS pod running in current cluster. */ async getIPFSPod(): Promise<string> { if (config.kubernetes.enabled) { const kc = new k8s.KubeConfig() kc.loadFromDefault() const k8DefaultClient = kc.makeApiClient(k8s.CoreV1Api) const appName = `${config.server.releaseName}-ipfs` const podsResult = await k8DefaultClient.listNamespacedPod( 'default', undefined, undefined, undefined, undefined, `app.kubernetes.io/instance==${appName}` ) if (podsResult.body.items.length > 0) { return podsResult.body.items[0].metadata!.name! } } return '' } private async _forwardIPFS(podName: string, forward: k8s.PortForward, forwardPort: number): Promise<string> { return new Promise((resolve) => { const server = net.createServer((socket) => { forward.portForward('default', podName, [forwardPort], socket, socket, socket) }) server.listen(0, '127.0.0.1', () => { const { port } = server.address() as net.AddressInfo const address = `127.0.0.1:${port}` console.log(`Listening IPFS port ${forwardPort} on: `, address) resolve(address) }) }) } private async _parseMFSDirectory( currentPath: string, results: { Key: string }[] ) { for await (const file of this._client.files.ls(currentPath)) { const fullPath = path.join(currentPath, file.name) results.push({ Key: fullPath }) if (file.type === 'directory') { await this._parseMFSDirectory(fullPath, results) } } } private async _parseMFSDirectoryAsType(currentPath: string, results: FileContentType[]) { for await (const file of this._client.files.ls(currentPath)) { const res: FileContentType = { key: file.cid.toString(), name: path.join(currentPath, file.name), type: file.type, url: file.cid.toString(), size: this._formatBytes(file.size) } results.push(res) const fullPath = path.join(currentPath, file.name) if (file.type === 'directory') { await this._parseMFSDirectoryAsType(fullPath, results) } } } private async _getUrl(assetPath: string): Promise<string> { if (!this.cacheDomain) throw new Error('No cache domain found - please check the storage provider configuration') const filePath = path.join(this._pathPrefix, assetPath) return this._client.files .stat(filePath) .then( (stats) => new URL( `/ipfs/${stats.cid.toString()}?filename=${encodeURI(path.basename(assetPath))}`, `http://${this.cacheDomain}` ).href ) .catch(() => new URL(`http://${this.cacheDomain}`).href) } private _formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes' const k = 1024 const dm = decimals < 0 ? 0 : decimals const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] } } /** * Blob store class for IPFS storage. */ class IPFSBlobStore implements BlobStore { private _client: IPFSHTTPClient /** * Path for the IPFS blob store. */ path: string = '/' /** * Cache for the IPFS blob store. */ cache: any /** * Constructor of IPFSBlobStore class. * @param client Instance of IPFSHTTPClient object to communicate with IPFS instance. */ constructor(client: IPFSHTTPClient) { this._client = client } /** * Creates a write stream for the IPFS blob store. * @param options Options for blob store. * @param cb Callback one the stream is ready. */ createWriteStream(options: string | { key: string }, cb?: (err: any, result: any) => void) { if (typeof options === 'string') options = { key: options } if (options['name']) options.key = options['name'] if (typeof options['flush'] === 'boolean' && options['flush'] === false) { } else { options['flush'] = true } const writePath = path.join(this.path, options.key) const bufferStream = new stream.PassThrough() let size = 0 bufferStream.on('data', (buffer) => { size += buffer.length }) this._client.files .write(writePath, bufferStream, { create: true, parents: true, flush: options['flush'] }) .then(() => { if (cb) cb(undefined, { key: options['key'], size: size, name: path.basename(writePath) }) }) .catch((error) => { if (cb) cb(error, undefined) }) return bufferStream } /** * Creates a read stream for the IPFS blob store. * @param key Key of object. * @param options Options for blob store. */ async createReadStream(key: string | { key: string }, options?: any) { if (typeof options === 'string') options = { key: options } if (options.name) options.key = options.name const readPath = path.join(this.path, options.key) const chunks: Uint8Array[] = [] for await (const chunk of this._client.files.read(readPath)) { chunks.push(chunk) } const chunksArray = uint8ArrayConcat(chunks) return { Body: stream.Readable.from(chunksArray), ContentType: 'application/octet-stream' } } /** * Checks whether an object exists in the IPFS blob store. * @param options Options for blob store. * @param cb Callback one the stream is ready. */ exists(options: string | { key: string }, cb?: (err: any, result: any) => void) { if (typeof options === 'string') options = { key: options } if (options['name']) options.key = options['name'] const statPath = path.join(this.path, options.key) this._client.files .stat(statPath) .then(() => { if (cb) cb(undefined, true) return true }) .catch(() => { if (cb) cb(true, undefined) return false }) } /** * Removes an object from the IPFS blob store. * @param options Options for blob store. * @param cb Callback one the stream is ready. */ remove(options: string | { key: string }, cb?: (err: any, result: any) => void) { if (typeof options === 'string') options = { key: options } if (options['name']) options.key = options['name'] const rmPath = path.join(this.path, options.key) this._client.files .rm(rmPath) .then(() => { if (cb) cb(undefined, true) return true }) .catch(() => { if (cb) cb(true, undefined) return false }) } } export default IPFSStorage