feat: upload module (#486)
* feat: wip upload module * feat: local storage and serve local images * feat: protect against injections * feat: server local and s3 files * fix: use storage location when serving local files * feat: cross field env validation
This commit is contained in:
35
server/src/core/file/services/file-upload.service.spec.ts
Normal file
35
server/src/core/file/services/file-upload.service.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FileUploadService } from './file-upload.service';
|
||||
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||
import { LocalStorageService } from 'src/integrations/local-storage/local-storage.service';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
|
||||
describe('FileUploadService', () => {
|
||||
let service: FileUploadService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FileUploadService,
|
||||
{
|
||||
provide: S3StorageService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: LocalStorageService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<FileUploadService>(FileUploadService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
135
server/src/core/file/services/file-upload.service.ts
Normal file
135
server/src/core/file/services/file-upload.service.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||
import { kebabCase } from 'src/utils/kebab-case';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { LocalStorageService } from 'src/integrations/local-storage/local-storage.service';
|
||||
import { getCropSize } from 'src/utils/image';
|
||||
import { settings } from 'src/constants/settings';
|
||||
import { FileFolder } from '../interfaces/file-folder.interface';
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadService {
|
||||
constructor(
|
||||
private readonly s3Storage: S3StorageService,
|
||||
private readonly localStorage: LocalStorageService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async uploadFile({
|
||||
file,
|
||||
name,
|
||||
mimeType,
|
||||
fileFolder,
|
||||
}: {
|
||||
file: Buffer | Uint8Array | string;
|
||||
name: string;
|
||||
mimeType: string | undefined;
|
||||
fileFolder: FileFolder;
|
||||
}) {
|
||||
const storageType = this.environmentService.getStorageType();
|
||||
|
||||
switch (storageType) {
|
||||
case 's3': {
|
||||
await this.uploadFileToS3(file, name, mimeType, fileFolder);
|
||||
return {
|
||||
name: `/${name}`,
|
||||
};
|
||||
}
|
||||
case 'local':
|
||||
default: {
|
||||
await this.uploadToLocal(file, name, fileFolder);
|
||||
return {
|
||||
name: `/${name}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage({
|
||||
file,
|
||||
name,
|
||||
mimeType,
|
||||
fileFolder,
|
||||
}: {
|
||||
file: Buffer | Uint8Array | string;
|
||||
name: string;
|
||||
mimeType: string | undefined;
|
||||
fileFolder: FileFolder;
|
||||
}) {
|
||||
// Get all cropSizes for this fileFolder
|
||||
const cropSizes = settings.storage.imageCropSizes[fileFolder];
|
||||
// Extract the values from ShortCropSize
|
||||
const sizes = cropSizes.map((shortSize) => getCropSize(shortSize));
|
||||
// Crop images based on sizes
|
||||
const images = await Promise.all(
|
||||
sizes.map((size) =>
|
||||
sharp(file).resize({
|
||||
[size?.type || 'width']: size?.value ?? undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Upload all images to corresponding folders
|
||||
await Promise.all(
|
||||
images.map(async (image, index) => {
|
||||
const buffer = await image.toBuffer();
|
||||
|
||||
return this.uploadFile({
|
||||
file: buffer,
|
||||
name: `${cropSizes[index]}/${name}`,
|
||||
mimeType,
|
||||
fileFolder,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: `/${name}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async uploadToLocal(
|
||||
file: Buffer | Uint8Array | string,
|
||||
name: string,
|
||||
fileFolder: FileFolder,
|
||||
): Promise<void> {
|
||||
const folderName = kebabCase(fileFolder.toString());
|
||||
|
||||
try {
|
||||
const result = await this.localStorage.uploadFile({
|
||||
file,
|
||||
name,
|
||||
folder: folderName,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.log('uploadFile error: ', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFileToS3(
|
||||
file: Buffer | Uint8Array | string,
|
||||
name: string,
|
||||
mimeType: string | undefined,
|
||||
fileFolder: FileFolder,
|
||||
) {
|
||||
// Aws only accept bucket with kebab-case name
|
||||
const bucketFolderName = kebabCase(fileFolder.toString());
|
||||
|
||||
try {
|
||||
const result = await this.s3Storage.uploadFile({
|
||||
Key: `${bucketFolderName}/${name}`,
|
||||
Body: file,
|
||||
ContentType: mimeType,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.log('uploadFile error: ', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
server/src/core/file/services/file.service.spec.ts
Normal file
30
server/src/core/file/services/file.service.spec.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FileService } from './file.service';
|
||||
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
|
||||
describe('FileService', () => {
|
||||
let service: FileService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FileService,
|
||||
{
|
||||
provide: S3StorageService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<FileService>(FileService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
55
server/src/core/file/services/file.service.ts
Normal file
55
server/src/core/file/services/file.service.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { S3StorageService } from 'src/integrations/s3-storage/s3-storage.service';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { createReadStream } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private readonly s3Storage: S3StorageService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async getFileStream(folderPath: string, filename: string) {
|
||||
const storageType = this.environmentService.getStorageType();
|
||||
|
||||
switch (storageType) {
|
||||
case 's3':
|
||||
return this.getS3FileStream(folderPath, filename);
|
||||
case 'local':
|
||||
default:
|
||||
return this.getLocalFileStream(folderPath, filename);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLocalFileStream(folderPath: string, filename: string) {
|
||||
const storageLocation = this.environmentService.getStorageLocation();
|
||||
|
||||
const filePath = join(
|
||||
process.cwd(),
|
||||
`${storageLocation}/`,
|
||||
folderPath,
|
||||
filename,
|
||||
);
|
||||
|
||||
return createReadStream(filePath);
|
||||
}
|
||||
|
||||
private async getS3FileStream(folderPath: string, filename: string) {
|
||||
try {
|
||||
const file = await this.s3Storage.getFile({
|
||||
Key: `${folderPath}/${filename}`,
|
||||
});
|
||||
|
||||
if (!file || !file.Body || !(file.Body instanceof Readable)) {
|
||||
throw new Error('Unable to get file stream');
|
||||
}
|
||||
|
||||
return Readable.from(file.Body);
|
||||
} catch (error) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user