@xrengine/server-core
Version:
Shared components for XREngine server
529 lines (450 loc) • 15.1 kB
text/typescript
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