Skip to content

Commit 99457d2

Browse files
authored
Merge pull request #233 from import-ai/refactor/upload
refactor(files): update upload method
2 parents 5cc99f5 + d3a85f0 commit 99457d2

File tree

12 files changed

+836
-5
lines changed

12 files changed

+836
-5
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
"typescript-eslint": "^8.46.3"
5454
},
5555
"dependencies": {
56+
"@aws-sdk/client-s3": "^3.926.0",
57+
"@aws-sdk/s3-presigned-post": "^3.926.0",
58+
"@aws-sdk/s3-request-presigner": "^3.926.0",
5659
"@keyv/redis": "^5.1.3",
5760
"@nestjs-modules/mailer": "^2.0.2",
5861
"@nestjs/cache-manager": "^3.0.1",

pnpm-lock.yaml

Lines changed: 673 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/files/files.service.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { HttpStatus, Injectable } from '@nestjs/common';
2-
import { AwsClient } from 'aws4fetch';
32
import { ConfigService } from '@nestjs/config';
43
import { Repository } from 'typeorm';
54
import { File } from './entities/file.entity';
65
import { InjectRepository } from '@nestjs/typeorm';
76
import { AppException } from 'omniboxd/common/exceptions/app.exception';
87
import { I18nService } from 'nestjs-i18n';
8+
import { S3Client } from '@aws-sdk/client-s3';
9+
import { createPresignedPost, PresignedPost } from '@aws-sdk/s3-presigned-post';
10+
import { AwsClient } from 'aws4fetch';
11+
import { formatFileSize } from '../utils/format-file-size';
912

1013
@Injectable()
1114
export class FilesService {
1215
private readonly awsClient: AwsClient;
1316
private readonly s3Url: URL;
1417
private readonly s3InternalUrl: URL;
18+
private readonly s3Client: S3Client;
19+
private readonly s3Bucket: string;
20+
private readonly s3Prefix: string;
21+
private readonly s3MaxFileSize: number;
1522

1623
constructor(
1724
configService: ConfigService,
@@ -43,9 +50,39 @@ export class FilesService {
4350
s3InternalUrl += '/';
4451
}
4552

53+
const s3Endpoint = configService.get<string>('OBB_S3_ENDPOINT');
54+
if (!s3Endpoint) {
55+
throw new Error('S3 endpoint not set');
56+
}
57+
58+
const s3Bucket = configService.get<string>('OBB_S3_BUCKET');
59+
if (!s3Bucket) {
60+
throw new Error('S3 bucket not set');
61+
}
62+
63+
const s3Prefix = configService.get<string>('OBB_S3_PREFIX');
64+
if (!s3Prefix) {
65+
throw new Error('S3 prefix not set');
66+
}
67+
4668
this.awsClient = new AwsClient({ accessKeyId, secretAccessKey });
4769
this.s3Url = new URL(s3Url);
4870
this.s3InternalUrl = new URL(s3InternalUrl);
71+
this.s3MaxFileSize = configService.get<number>(
72+
'OBB_S3_MAX_FILE_SIZE',
73+
20 * 1024 * 1024,
74+
);
75+
const s3Region = configService.get<string>('OBB_S3_REGION', 'us-east-1');
76+
this.s3Client = new S3Client({
77+
region: s3Region,
78+
credentials: {
79+
accessKeyId,
80+
secretAccessKey,
81+
},
82+
endpoint: s3Endpoint,
83+
});
84+
this.s3Bucket = s3Bucket;
85+
this.s3Prefix = s3Prefix;
4986
}
5087

5188
async createFile(
@@ -81,6 +118,38 @@ export class FilesService {
81118
return signedReq.url;
82119
}
83120

121+
async generatePostForm(
122+
fileId: string,
123+
fileSize: number | undefined,
124+
filename: string,
125+
mimetype: string,
126+
): Promise<PresignedPost> {
127+
if (fileSize && fileSize > this.s3MaxFileSize) {
128+
const message = this.i18n.t('resource.errors.fileTooLarge', {
129+
args: {
130+
userSize: formatFileSize(fileSize),
131+
limitSize: formatFileSize(this.s3MaxFileSize),
132+
},
133+
});
134+
throw new AppException(message, 'FILE_TOO_LARGE', HttpStatus.BAD_REQUEST);
135+
}
136+
const disposition = `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`;
137+
return await createPresignedPost(this.s3Client, {
138+
Bucket: this.s3Bucket,
139+
Key: `${this.s3Prefix}/${fileId}`,
140+
Conditions: [
141+
['content-length-range', 0, this.s3MaxFileSize],
142+
{ 'content-type': mimetype },
143+
{ 'content-disposition': disposition },
144+
],
145+
Fields: {
146+
'content-type': mimetype,
147+
'content-disposition': disposition,
148+
},
149+
Expires: 900, // 900 seconds
150+
});
151+
}
152+
84153
private async generateDownloadUrl(
85154
namespaceId: string,
86155
fileId: string,

src/i18n/en/resource.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"parentOrResourceIdRequired": "parent_id or resource_id is required",
55
"resourceNotFound": "Resource not found",
66
"fileNotFound": "File not found",
7+
"fileTooLarge": "Your file ({userSize}) exceeds the maximum limit of {limitSize}",
78
"cannotDeleteRoot": "Cannot delete root resource",
89
"cannotDuplicateRoot": "Cannot duplicate root resource",
910
"cannotRestoreRoot": "Cannot restore root resource",

src/i18n/zh/resource.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"parentOrResourceIdRequired": "需要 parent_id 或 resource_id",
55
"resourceNotFound": "资源未找到",
66
"fileNotFound": "文件未找到",
7+
"fileTooLarge": "您的文件({userSize})超过最大限制 {limitSize}",
78
"cannotDeleteRoot": "无法删除根资源",
89
"cannotDuplicateRoot": "无法复制根资源",
910
"cannotRestoreRoot": "无法恢复根资源",

src/namespace-resources/dto/create-file-req.dto.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Expose } from 'class-transformer';
2-
import { IsNotEmpty, IsString } from 'class-validator';
2+
import {
3+
IsNotEmpty,
4+
IsNumber,
5+
IsOptional,
6+
IsString,
7+
Min,
8+
} from 'class-validator';
39

410
export class CreateFileReqDto {
511
@Expose()
@@ -11,4 +17,10 @@ export class CreateFileReqDto {
1117
@IsString()
1218
@IsNotEmpty()
1319
mimetype: string;
20+
21+
@Expose()
22+
@IsNumber()
23+
@Min(1)
24+
@IsOptional()
25+
size?: number;
1426
}

src/namespace-resources/dto/file-info.dto.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,25 @@ export class FileInfoDto {
77
@Expose()
88
url: string;
99

10-
static new(id: string, url: string) {
10+
@Expose({ name: 'post_url' })
11+
postUrl?: string;
12+
13+
@Expose({ name: 'post_fields' })
14+
postFields?: [string, string][];
15+
16+
static new(
17+
id: string,
18+
url: string,
19+
postUrl?: string,
20+
postFields?: Record<string, string>,
21+
) {
1122
const dto = new FileInfoDto();
1223
dto.id = id;
1324
dto.url = url;
25+
dto.postUrl = postUrl;
26+
if (postFields) {
27+
dto.postFields = Object.entries(postFields);
28+
}
1429
return dto;
1530
}
1631
}

src/namespace-resources/file-resources.e2e-spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ describe('FileResourcesController (e2e)', () => {
2626

2727
test.each(uploadLanguageDatasets)(
2828
'upload and download file: $filename',
29-
async ({ filename }) => {
29+
async ({ filename, content }) => {
3030
const uploadRes = await client
3131
.post(`/api/v1/namespaces/${client.namespace.id}/resources/files`)
3232
.send({
3333
name: filename,
3434
mimetype: 'text/plain',
35+
size: content.length,
3536
});
3637
expect(uploadRes.status).toBe(201);
3738
},

src/namespace-resources/namespace-resources.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,13 @@ export class NamespaceResourcesService {
715715
createReq.mimetype,
716716
);
717717
const url = await this.filesService.generateUploadUrl(file.id);
718-
return FileInfoDto.new(file.id, url);
718+
const postReq = await this.filesService.generatePostForm(
719+
file.id,
720+
createReq.size,
721+
file.name,
722+
file.mimetype,
723+
);
724+
return FileInfoDto.new(file.id, url, postReq.url, postReq.fields);
719725
}
720726

721727
async update(userId: string, resourceId: string, data: UpdateResourceDto) {

src/utils/format-file-size.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { formatFileSize } from './format-file-size';
2+
3+
describe('formatFileSize', () => {
4+
it('should format bytes correctly', () => {
5+
expect(formatFileSize(0)).toBe('0 B');
6+
expect(formatFileSize(100)).toBe('100 B');
7+
expect(formatFileSize(1023)).toBe('1023 B');
8+
});
9+
10+
it('should format kilobytes correctly', () => {
11+
expect(formatFileSize(1024)).toBe('1.0 KB');
12+
expect(formatFileSize(1536)).toBe('1.5 KB');
13+
expect(formatFileSize(10240)).toBe('10.0 KB');
14+
});
15+
16+
it('should format megabytes correctly', () => {
17+
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
18+
expect(formatFileSize(20 * 1024 * 1024)).toBe('20.0 MB');
19+
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
20+
});
21+
22+
it('should format gigabytes correctly', () => {
23+
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1.0 GB');
24+
expect(formatFileSize(2.5 * 1024 * 1024 * 1024)).toBe('2.5 GB');
25+
});
26+
27+
it('should format terabytes correctly', () => {
28+
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB');
29+
});
30+
});

0 commit comments

Comments
 (0)