Browse Source

feat: Implement scanFiles for gcs and Minio (#9463)

* fix: bump gcs version fix: implement scan files for gcs and minio

* fix: stream files
pull/9493/head
Anbarasu 2 months ago committed by GitHub
parent
commit
fe02f9eb3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 212
      packages/nocodb/src/plugins/gcs/Gcs.ts
  2. 46
      packages/nocodb/src/plugins/mino/Minio.ts

212
packages/nocodb/src/plugins/gcs/Gcs.ts

@ -1,18 +1,18 @@
import fs from 'fs'; import fs from 'fs';
import { promisify } from 'util'; import { promisify } from 'util';
import { Readable } from 'stream';
import { Storage } from '@google-cloud/storage'; import { Storage } from '@google-cloud/storage';
import axios from 'axios'; import axios from 'axios';
import { useAgent } from 'request-filtering-agent'; import { useAgent } from 'request-filtering-agent';
import type { GetSignedUrlConfig, StorageOptions } from '@google-cloud/storage'; import type { GetSignedUrlConfig, StorageOptions } from '@google-cloud/storage';
import type { IStorageAdapterV2, XcFile } from '~/types/nc-plugin'; import type { IStorageAdapterV2, XcFile } from '~/types/nc-plugin';
import type { Readable } from 'stream';
import { generateTempFilePath, waitForStreamClose } from '~/utils/pluginUtils'; import { generateTempFilePath, waitForStreamClose } from '~/utils/pluginUtils';
interface GoogleCloudStorageInput { interface GoogleCloudStorageInput {
client_email: string; client_email: string;
private_key: string; private_key: string;
bucket: string; bucket: string;
project_id: string; project_id?: string;
} }
export default class Gcs implements IStorageAdapterV2 { export default class Gcs implements IStorageAdapterV2 {
@ -22,23 +22,32 @@ export default class Gcs implements IStorageAdapterV2 {
private bucketName: string; private bucketName: string;
private input: GoogleCloudStorageInput; private input: GoogleCloudStorageInput;
constructor(input: unknown) { constructor(input: GoogleCloudStorageInput) {
this.input = input as GoogleCloudStorageInput; this.input = input;
} }
public async init(): Promise<any> { protected patchKey(key: string): string {
const options: StorageOptions = {}; let patchedKey = decodeURIComponent(key);
options.credentials = { if (patchedKey.startsWith(`${this.bucketName}/`)) {
client_email: this.input.client_email, patchedKey = patchedKey.replace(`${this.bucketName}/`, '');
// replace \n with real line breaks to avoid }
// error:0909006C:PEM routines:get_name:no start line return patchedKey;
private_key: this.input.private_key.replace(/\\n/gm, '\n'), }
public async init(): Promise<void> {
const options: StorageOptions = {
credentials: {
client_email: this.input.client_email,
// replace \n with real line breaks to avoid
// error:0909006C:PEM routines:get_name:no start line
private_key: this.input.private_key.replace(/\\n/gm, '\n'),
},
}; };
// default project ID would be used if it is not provided
if (this.input.project_id) { if (this.input.project_id) {
options.projectId = this.input.project_id; options.projectId = this.input.project_id;
} }
this.bucketName = this.input.bucket; this.bucketName = this.input.bucket;
this.storageClient = new Storage(options); this.storageClient = new Storage(options);
} }
@ -50,9 +59,9 @@ export default class Gcs implements IStorageAdapterV2 {
await waitForStreamClose(createStream); await waitForStreamClose(createStream);
await this.fileCreate('nc-test-file.txt', { await this.fileCreate('nc-test-file.txt', {
path: tempFile, path: tempFile,
mimetype: '', mimetype: 'text/plain',
originalname: 'temp.txt', originalname: 'temp.txt',
size: '', size: createStream.bytesWritten.toString(),
}); });
await promisify(fs.unlink)(tempFile); await promisify(fs.unlink)(tempFile);
return true; return true;
@ -61,112 +70,104 @@ export default class Gcs implements IStorageAdapterV2 {
} }
} }
public fileRead(key: string): Promise<any> { public async fileRead(key: string): Promise<Buffer> {
return new Promise((resolve, reject) => { const file = this.storageClient
const file = this.storageClient.bucket(this.bucketName).file(key); .bucket(this.bucketName)
// Check for existence, since gcloud-node seemed to be caching the result .file(this.patchKey(key));
file.exists((err, exists) => { const [exists] = await file.exists();
if (exists) { if (!exists) {
file.download((downerr, data) => { throw new Error(`File ${this.patchKey(key)} does not exist`);
if (err) { }
return reject(downerr); const [data] = await file.download();
} return data;
return resolve(data);
});
} else {
reject(err);
}
});
});
} }
async fileCreate(key: string, file: XcFile): Promise<any> { public async fileCreate(key: string, file: XcFile): Promise<string> {
const uploadResponse = await this.storageClient const [uploadResponse] = await this.storageClient
.bucket(this.bucketName) .bucket(this.bucketName)
.upload(file.path, { .upload(file.path, {
destination: key, destination: this.patchKey(key),
contentType: file?.mimetype || 'application/octet-stream', contentType: file?.mimetype || 'application/octet-stream',
gzip: true, gzip: true,
predefinedAcl: 'publicRead',
metadata: { metadata: {
cacheControl: 'public, max-age=31536000', cacheControl: 'public, max-age=31536000',
}, },
}); });
return uploadResponse[0].publicUrl(); return uploadResponse.publicUrl();
} }
async fileCreateByStream( public async fileCreateByStream(
key: string, key: string,
stream: Readable, stream: Readable,
options?: { options: {
mimetype?: string; mimetype?: string;
}, size?: number;
): Promise<void> { } = {},
const uploadResponse = await this.storageClient ): Promise<any> {
const file = this.storageClient
.bucket(this.bucketName) .bucket(this.bucketName)
.file(key) .file(this.patchKey(key));
.save(stream, { await new Promise<void>((resolve, reject) => {
// Support for HTTP requests made with `Accept-Encoding: gzip` stream
gzip: true, .pipe(
// By setting the option `destination`, you can change the name of the file.createWriteStream({
// object you are uploading to a bucket. gzip: true,
metadata: { predefinedAcl: 'publicRead',
contentType: options.mimetype || 'application/octet-stream', metadata: {
// Enable long-lived HTTP caching headers contentType: options.mimetype || 'application/octet-stream',
// Use only if the contents of the file will never change cacheControl: 'public, max-age=31536000',
// (If the contents will change, use cacheControl: 'no-cache') },
cacheControl: 'public, max-age=31536000', }),
}, )
}); .on('finish', () => resolve())
.on('error', reject);
});
return uploadResponse[0].publicUrl(); return file.publicUrl();
} }
async fileCreateByUrl( public async fileCreateByUrl(
destPath: string, destPath: string,
url: string, url: string,
{ fetchOptions: { buffer } = { buffer: false } }, { fetchOptions: { buffer } = { buffer: false } },
): Promise<any> { ): Promise<{ url: string; data: any }> {
return new Promise((resolve, reject) => { const response = await axios.get(url, {
axios httpAgent: useAgent(url, { stopPortScanningByUrlRedirection: true }),
.get(url, { httpsAgent: useAgent(url, { stopPortScanningByUrlRedirection: true }),
httpAgent: useAgent(url, { stopPortScanningByUrlRedirection: true }), responseType: buffer ? 'arraybuffer' : 'stream',
httpsAgent: useAgent(url, { stopPortScanningByUrlRedirection: true }),
responseType: buffer ? 'arraybuffer' : 'stream',
})
.then((response) => {
this.storageClient
.bucket(this.bucketName)
.file(destPath)
.save(response.data)
.then((res) => resolve({ url: res, data: response.data }))
.catch(reject);
})
.catch((error) => {
reject(error);
});
}); });
const file = this.storageClient.bucket(this.bucketName).file(destPath);
await file.save(response.data);
return { url: file.publicUrl(), data: response.data };
} }
fileDelete(_path: string): Promise<any> { public async fileDelete(path: string): Promise<void> {
throw new Error('Method not implemented.'); await this.storageClient.bucket(this.bucketName).file(path).delete();
} }
// TODO - implement public async fileReadByStream(key: string): Promise<Readable> {
fileReadByStream(_key: string): Promise<Readable> { return this.storageClient
return Promise.resolve(undefined); .bucket(this.bucketName)
.file(this.patchKey(key))
.createReadStream();
} }
// TODO - implement public async getDirectoryList(path: string): Promise<string[]> {
getDirectoryList(_path: string): Promise<string[]> { const [files] = await this.storageClient.bucket(this.bucketName).getFiles({
return Promise.resolve(undefined); prefix: path,
});
return files.map((file) => file.name);
} }
public async getSignedUrl( public async getSignedUrl(
key, key: string,
expiresInSeconds = 7200, expiresInSeconds = 7200,
pathParameters?: { [key: string]: string }, pathParameters?: { [key: string]: string },
) { ): Promise<string> {
const options: GetSignedUrlConfig = { const options: GetSignedUrlConfig = {
version: 'v4', version: 'v4',
action: 'read', action: 'read',
@ -176,13 +177,48 @@ export default class Gcs implements IStorageAdapterV2 {
const [url] = await this.storageClient const [url] = await this.storageClient
.bucket(this.bucketName) .bucket(this.bucketName)
.file(key) .file(this.patchKey(key))
.getSignedUrl(options); .getSignedUrl(options);
return url; return url;
} }
public async scanFiles(_globPattern: string): Promise<Readable> { public async scanFiles(globPattern: string): Promise<Readable> {
return Promise.resolve(undefined); // Remove all dots from the prefix
globPattern = globPattern.replace(/\./g, '');
// Remove the leading slash
globPattern = globPattern.replace(/^\//, '');
// Make sure pattern starts with nc/uploads/
if (!globPattern.startsWith('nc/uploads/')) {
globPattern = `nc/uploads/${globPattern}`;
}
const stream = new Readable({
objectMode: true,
read() {},
});
const fileStream = this.storageClient
.bucket(this.input.bucket)
.getFilesStream({
prefix: globPattern,
autoPaginate: true,
});
fileStream.on('error', (error) => {
stream.emit('error', error);
});
fileStream.on('data', (file) => {
stream.push(file.name);
});
fileStream.on('end', () => {
stream.push(null);
});
return stream;
} }
} }

46
packages/nocodb/src/plugins/mino/Minio.ts

@ -204,7 +204,49 @@ export default class Minio implements IStorageAdapterV2 {
}); });
} }
public async scanFiles(_globPattern: string): Promise<Readable> { public async scanFiles(globPattern: string): Promise<Readable> {
return Promise.resolve(undefined); // Remove all dots from the glob pattern
globPattern = globPattern.replace(/\./g, '');
// Remove the leading slash
globPattern = globPattern.replace(/^\//, '');
// Make sure pattern starts with nc/uploads/
if (!globPattern.startsWith('nc/uploads/')) {
globPattern = `nc/uploads/${globPattern}`;
}
// Minio does not support glob so remove *
globPattern = globPattern.replace(/\*/g, '');
const stream = new Readable({
read() {},
});
stream.setEncoding('utf8');
const listObjects = async () => {
try {
const objectStream = this.minioClient.listObjectsV2(
this.input.bucket,
globPattern,
true,
);
for await (const item of objectStream) {
stream.push(item.name);
}
stream.push(null);
} catch (error) {
stream.emit('error', error);
}
};
listObjects().catch((error) => {
stream.emit('error', error);
});
return stream;
} }
} }

Loading…
Cancel
Save