apps/nestjs-backend/src/features/attachments/plugins/minio.ts
/* eslint-disable @typescript-eslint/naming-convention */
import type { Readable as ReadableStream } from 'node:stream';
import { join, resolve } from 'path';
import { BadRequestException, Injectable } from '@nestjs/common';
import { getRandomString } from '@teable/core';
import * as fse from 'fs-extra';
import * as minio from 'minio';
import sharp from 'sharp';
import { IStorageConfig, StorageConfig } from '../../../configs/storage';
import { second } from '../../../utils/second';
import StorageAdapter from './adapter';
import type { IPresignParams, IPresignRes, IRespHeaders } from './types';
@Injectable()
export class MinioStorage implements StorageAdapter {
minioClient: minio.Client;
minioClientPrivateNetwork: minio.Client;
constructor(@StorageConfig() readonly config: IStorageConfig) {
const { endPoint, internalEndPoint, internalPort, port, useSSL, accessKey, secretKey } =
this.config.minio;
this.minioClient = new minio.Client({
endPoint: endPoint!,
port: port!,
useSSL: useSSL!,
accessKey: accessKey!,
secretKey: secretKey!,
});
this.minioClientPrivateNetwork = internalEndPoint
? new minio.Client({
endPoint: internalEndPoint,
port: internalPort,
useSSL: false,
accessKey: accessKey!,
secretKey: secretKey!,
})
: this.minioClient;
fse.ensureDirSync(StorageAdapter.TEMPORARY_DIR);
}
async presigned(
bucket: string,
dir: string,
presignedParams: IPresignParams
): Promise<IPresignRes> {
const { tokenExpireIn, uploadMethod } = this.config;
const { expiresIn, contentLength, contentType, hash, internal } = presignedParams;
const token = getRandomString(12);
const filename = hash ?? token;
const path = join(dir, filename);
const requestHeaders = {
'Content-Type': contentType,
'Content-Length': contentLength,
'response-cache-control': 'max-age=31536000, immutable',
};
try {
const client = internal ? this.minioClientPrivateNetwork : this.minioClient;
const url = await client.presignedUrl(
uploadMethod,
bucket,
path,
expiresIn ?? second(tokenExpireIn),
requestHeaders
);
return {
url,
path,
token,
uploadMethod,
requestHeaders,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
throw new BadRequestException(`Minio presigned error${e?.message ? `: ${e.message}` : ''}`);
}
}
private async getShape(bucket: string, objectName: string) {
try {
const stream = await this.minioClientPrivateNetwork.getObject(bucket, objectName);
const metaReader = sharp();
const sharpReader = stream.pipe(metaReader);
const { width, height } = await sharpReader.metadata();
return {
width,
height,
};
} catch (e) {
return {};
}
}
async getObjectMeta(bucket: string, path: string, _token: string) {
const objectName = path;
const {
metaData,
size,
etag: hash,
} = await this.minioClientPrivateNetwork.statObject(bucket, objectName);
const mimetype = metaData['content-type'] as string;
const url = `/${bucket}/${objectName}`;
if (!mimetype?.startsWith('image/')) {
return {
hash,
size,
mimetype,
url,
};
}
const sharpMeta = await this.getShape(bucket, objectName);
return {
...sharpMeta,
hash,
size,
mimetype,
url,
};
}
async getPreviewUrl(
bucket: string,
path: string,
expiresIn: number = second(this.config.urlExpireIn),
respHeaders?: IRespHeaders
) {
if (!(await this.fileExists(bucket, path))) {
return;
}
const { 'Content-Disposition': contentDisposition, ...headers } = respHeaders ?? {};
return this.minioClient.presignedGetObject(bucket, path, expiresIn, {
...headers,
'response-content-disposition': contentDisposition,
});
}
async uploadFileWidthPath(
bucket: string,
path: string,
filePath: string,
metadata: Record<string, string | number>
) {
const { etag: hash } = await this.minioClientPrivateNetwork.fPutObject(
bucket,
path,
filePath,
metadata
);
return {
hash,
path,
};
}
async uploadFile(
bucket: string,
path: string,
stream: Buffer | ReadableStream,
metadata: Record<string, string | number>
) {
const { etag: hash } = await this.minioClientPrivateNetwork.putObject(
bucket,
path,
stream,
undefined,
metadata
);
return {
hash,
path,
};
}
// minio file exists
private async fileExists(bucket: string, path: string) {
try {
await this.minioClientPrivateNetwork.statObject(bucket, path);
return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
return false;
}
throw err;
}
}
async cropImage(
bucket: string,
path: string,
width?: number,
height?: number,
_newPath?: string
) {
const newPath = _newPath || `${path}_${width ?? 0}_${height ?? 0}`;
const resizedImagePath = resolve(
StorageAdapter.TEMPORARY_DIR,
encodeURIComponent(join(bucket, newPath))
);
if (await this.fileExists(bucket, newPath)) {
return newPath;
}
const objectName = path;
const { metaData } = await this.minioClientPrivateNetwork.statObject(bucket, objectName);
const mimetype = metaData['content-type'] as string;
if (!mimetype?.startsWith('image/')) {
throw new BadRequestException('Invalid image');
}
const stream = await this.minioClientPrivateNetwork.getObject(bucket, objectName);
const metaReader = sharp({ failOn: 'none', unlimited: true }).resize(width, height);
const sharpReader = stream.pipe(metaReader);
await sharpReader.toFile(resizedImagePath);
const upload = await this.uploadFileWidthPath(bucket, newPath, resizedImagePath, {
'Content-Type': mimetype,
});
// delete resized image
fse.removeSync(resizedImagePath);
return upload.path;
}
}