const https = require('https'); const awsClient = require('./vendor/awsClient.js'); module.exports = function(file, getEncryptionInstanceFromPassphrase) { if (!file) { return null; } const isBackBlaze = file['choose-providers'] && file['choose-providers'].objectStorage == 'backblaze'; const hasBackBlazeKey = file['backblaze-account'] && file['backblaze-account'].secretKey; const isS3Compatible = file['choose-providers'] && file['choose-providers'].objectStorage == 's3compatible'; const hasS3CompatibleKey = file['s3compatible-account'] && file['s3compatible-account'].secretKey; let toReturn = null; if(file['cloud-service-type'] == 'greenhouse') { throw new Error("greenhouse object storage not implemented"); } else if(isBackBlaze && hasBackBlazeKey) { toReturn = new BackblazeObjectStorage(file) } else if(isS3Compatible && hasS3CompatibleKey) { toReturn = new S3CompatibleObjectStorage(file) } if(toReturn && file.passphrase) { toReturn = new E2EEObjectStorage(toReturn, getEncryptionInstanceFromPassphrase(file.passphrase)) } return toReturn; } const httpsPromise = (method, url, headers, body) => { if(typeof body == 'object') { body = JSON.stringify(body); } if(typeof body == 'string') { body = Buffer.from(body); } if(body) { headers['Content-Length'] = body.length; } const options = { method, headers, timeout: 5000, }; return new Promise((resolve, reject) => { //console.log(`${options.method} ${url}`); const request = https.request(url, options, (response) => { //console.log(`GOT ${options.method} ${url}`); let responseBody = null; response.on('data', (chunk) => responseBody = responseBody ? Buffer.concat([responseBody, chunk]) : chunk); response.on('end', () => { resolve({ response, responseBody }) }); }); request.on('error', (err) => { reject(err) }); request.on('abort', () => { reject(new Error(`${options.method} ${url}: aborted`)); }); // request.on('timeout', () => { // console.log(`TIMEDOUT ${options.method} ${url}`); // reject(new Error(`${options.method} ${url}: timed out`)); // request.destroy(); // }); if(body) { request.write(body); } request.end(); }); } function BackblazeObjectStorage(file) { const b2AuthorizeURL = "https://api.backblazeb2.com/b2api/v2/b2_authorize_account" let keyId = file['backblaze-account'].keyId; let secretKey = file['backblaze-account'].secretKey; let bucketName = file['backblaze-account'].bucketName; let authorizationToken = null; let uploadAuthorizationToken = null; let apiUrl = null; let uploadUrl = null; let downloadUrl = null; let accountId = null; let bucketId = null; if (!keyId || !secretKey || !bucketName) { throw new Error("can't makeBackblazeObjectStorage: username, password, and bucketName are all required.") } const login = async () => { const headers = { Authorization: 'Basic ' + Buffer.from(keyId + ':' + secretKey).toString('base64') }; let {response, responseBody} = await httpsPromise("GET", b2AuthorizeURL, headers); responseBody = responseBody.toString('utf-8') let responseObject = JSON.parse(responseBody); if(response.statusCode < 300) { //const responseObject = JSON.parse(responseBody); apiUrl = responseObject.apiUrl; downloadUrl = responseObject.downloadUrl; accountId = responseObject.accountId; authorizationToken = responseObject.authorizationToken; let {response, responseBody} = await httpsPromise( "GET", `${apiUrl}/b2api/v2/b2_list_buckets?accountId=${accountId}&bucketName=${bucketName}`, { Authorization: authorizationToken } ); responseBody = responseBody.toString('utf-8'); if(response.statusCode < 300) { const responseObject = JSON.parse(responseBody); if(responseObject.buckets.length < 1) { return {bucketNotFound: true}; } else { bucketId = responseObject.buckets[0].bucketId; return {bucketNotFound: false}; } } else { throw new Error(`backblaze error on b2_list_buckets: ${responseBody}`) } } else { throw new Error(`backblaze authorize error: ${responseBody}`) } }; this.createIfNotExists = async () => { let ensureLogin = {bucketNotFound: false}; if(!authorizationToken || !apiUrl || !bucketId) { ensureLogin = await login(); } if(ensureLogin.bucketNotFound) { const {response, responseBody} = await httpsPromise( "POST", `${apiUrl}/b2api/v2/b2_create_bucket`, { Authorization: authorizationToken }, { bucketName, accountId, bucketType: "allPrivate", lifecycleRules: [ { daysFromHidingToDeleting: 1, fileNamePrefix: "" } ] } ); if(response.statusCode < 300) { const responseObject = JSON.parse(responseBody.toString('utf-8')); bucketId = responseObject.bucketId; } else { throw new Error(`backblaze error on b2_create_bucket: ${responseBody.toString('utf-8')}`) } } }; this.createAccessKeyIfNotExists = (key) => { throw new Error("createAccessKeyIfNotExists not implemented"); }; this.list = async (key) => { if(!authorizationToken || !apiUrl || !bucketId) { const result = await login(); if(result.bucketNotFound) { throw new Error("ERROR_BUCKET_DOES_NOT_EXIST_YET"); } } if(key[0] == '/') { key = key.substring(1); } if(key[key.length-1] != '/') { key = `${key}/`; } const url = `${apiUrl}/b2api/v2/b2_list_file_names?bucketId=${bucketId}&delimiter=/${key != "" ? `&prefix=${key}` : ""}`; const {response, responseBody} = await httpsPromise( "GET", url, { Authorization: authorizationToken } ); //console.log(responseBody.toString('utf-8')); const responseObject = JSON.parse(responseBody.toString('utf-8')); if(response.statusCode > 299) { throw new Error(`Got HTTP ${response.statusCode} from ${url}`) } return responseObject.files.map(x => ({ name: `/${x.fileName}`.substring(key.length).replace(/(^\/)|(\/$)/g,''), isDirectory: x.action == "folder", lastModified: Number(x.uploadTimestamp) })); }; this.get = async (key) => { if(!authorizationToken || !apiUrl || !bucketId) { const result = await login(); if(result.bucketNotFound) { throw new Error("ERROR_BUCKET_DOES_NOT_EXIST_YET"); } } if(key[0] == '/') { key = key.substring(1); } const url = `${downloadUrl}/file/${bucketName}/${key}`; const {response, responseBody} = await httpsPromise( "GET", url, { Authorization: authorizationToken } ); if(response.statusCode == 404) { return {notFound: true}; } if(response.statusCode > 299) { throw new Error(`Got HTTP ${response.statusCode} from ${url}`); } const pathParts = key.split('/'); return { notFound: false, file: { name: pathParts[pathParts.length-1], content: responseBody, lastModified: Number(response.headers['x-bz-upload-timestamp']) } } }; this.put = (key, content) => { throw new Error("put not implemented"); }; this.delete = (key) => { throw new Error("delete not implemented"); }; } function E2EEObjectStorage(underlying, encryptionInstance) { this.createIfNotExists = async () => await underlying.createIfNotExists(key); this.createAccessKeyIfNotExists = async (key) => await underlying.createAccessKeyIfNotExists(key); this.list = async (key) => await underlying.list(key); this.get = async (key, skipDecrypt) => { const result = await underlying.get(key); if(result.file && result.file.content && !skipDecrypt) { const toDecrypt = Buffer.from(result.file.content); try { result.file.content = Buffer.from(encryptionInstance.decryptToBytes(toDecrypt)); } catch (err) { console.log(`get ${key} from object storage: decryption failed, returning unmodified content'`, err) } } return result; }; this.put = async (key, content) => { const isByteArray = content instanceof Uint8Array; if(typeof content == "string") { content = encryptionInstance.encryptString(content) } else if(typeof content == "object" && !isByteArray) { content = encryptionInstance.encryptString(JSON.stringify(content)) } else if (isByteArray) { content = encryptionInstance.encryptBytes(content) } return await underlying.put(key, content) }; this.delete = async (key) => await underlying.delete(key); } function S3CompatibleObjectStorage(file) { throw new Error("S3CompatibleObjectStorage not implemented"); }