Skip to content

Commit 252eea4

Browse files
authored
Merge pull request #224 from import-ai/refactor/files
refactor(s3): use s3
2 parents 8056b5a + 8d237fe commit 252eea4

27 files changed

+525
-78
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"@opentelemetry/sdk-node": "^0.203.0",
7777
"@opentelemetry/sdk-trace-base": "^2.1.0",
7878
"@opentelemetry/semantic-conventions": "^1.37.0",
79+
"aws4fetch": "^1.0.20",
7980
"bcrypt": "^5.1.1",
8081
"cache-manager": "^6.4.3",
8182
"cacheable": "^2.1.1",

pnpm-lock.yaml

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

src/app/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ import KeyvRedis from '@keyv/redis';
6969
import { Keyv } from 'keyv';
7070
import { CacheableMemory } from 'cacheable';
7171
import { isEmpty } from 'omniboxd/utils/is-empty';
72+
import { Files1761556143000 } from 'omniboxd/migrations/1761556143000-files';
73+
import { FilesModule } from 'omniboxd/files/files.module';
74+
import { AddFileIdToResources1761726974942 } from 'omniboxd/migrations/1761726974942-add-file-id-to-resources';
7275

7376
@Module({})
7477
export class AppModule implements NestModule {
@@ -140,6 +143,7 @@ export class AppModule implements NestModule {
140143
FeedbackModule,
141144
ApplicationsModule,
142145
WebSocketModule,
146+
FilesModule,
143147
CacheModule.registerAsync({
144148
isGlobal: true,
145149
imports: [ConfigModule],
@@ -187,6 +191,8 @@ export class AppModule implements NestModule {
187191
NullableUserId1757844448000,
188192
AddShareIdToConversations1757844449000,
189193
ShareUser1760171824000,
194+
Files1761556143000,
195+
AddFileIdToResources1761726974942,
190196
...extraMigrations,
191197
],
192198
migrationsRun: true,

src/files/entities/file.entity.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Base } from 'omniboxd/common/base.entity';
2+
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
3+
4+
@Entity('files')
5+
export class File extends Base {
6+
@PrimaryGeneratedColumn()
7+
id: string;
8+
9+
@Column()
10+
namespaceId: string;
11+
12+
@Column()
13+
userId: string;
14+
15+
@Column()
16+
name: string;
17+
18+
@Column()
19+
mimetype: string;
20+
}

src/files/files.module.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { FilesService } from './files.service';
3+
import { ConfigModule } from '@nestjs/config';
4+
import { TypeOrmModule } from '@nestjs/typeorm';
5+
import { File } from './entities/file.entity';
6+
7+
@Module({
8+
imports: [ConfigModule, TypeOrmModule.forFeature([File])],
9+
providers: [FilesService],
10+
exports: [FilesService],
11+
})
12+
export class FilesModule {}

src/files/files.service.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { HttpStatus, Injectable } from '@nestjs/common';
2+
import { AwsClient } from 'aws4fetch';
3+
import { ConfigService } from '@nestjs/config';
4+
import { Repository } from 'typeorm';
5+
import { File } from './entities/file.entity';
6+
import { InjectRepository } from '@nestjs/typeorm';
7+
import { AppException } from 'omniboxd/common/exceptions/app.exception';
8+
import { I18nService } from 'nestjs-i18n';
9+
10+
@Injectable()
11+
export class FilesService {
12+
private readonly awsClient: AwsClient;
13+
private readonly s3Url: URL;
14+
private readonly s3InternalUrl: URL;
15+
16+
constructor(
17+
configService: ConfigService,
18+
19+
@InjectRepository(File)
20+
private readonly fileRepo: Repository<File>,
21+
private readonly i18n: I18nService,
22+
) {
23+
const accessKeyId = configService.get<string>('OBB_S3_ACCESS_KEY_ID');
24+
const secretAccessKey = configService.get<string>(
25+
'OBB_S3_SECRET_ACCESS_KEY',
26+
);
27+
if (!accessKeyId || !secretAccessKey) {
28+
throw new Error('S3 credentials not set');
29+
}
30+
31+
let s3Url = configService.get<string>('OBB_S3_URL');
32+
if (!s3Url) {
33+
throw new Error('S3 URL not set');
34+
}
35+
if (!s3Url.endsWith('/')) {
36+
s3Url += '/';
37+
}
38+
39+
let s3InternalUrl = configService.get<string>('OBB_S3_INTERNAL_URL');
40+
if (!s3InternalUrl) {
41+
s3InternalUrl = s3Url;
42+
} else if (!s3InternalUrl.endsWith('/')) {
43+
s3InternalUrl += '/';
44+
}
45+
46+
this.awsClient = new AwsClient({ accessKeyId, secretAccessKey });
47+
this.s3Url = new URL(s3Url);
48+
this.s3InternalUrl = new URL(s3InternalUrl);
49+
}
50+
51+
async createFile(
52+
userId: string,
53+
namespaceId: string,
54+
filename: string,
55+
mimetype: string,
56+
): Promise<File> {
57+
return await this.fileRepo.save(
58+
this.fileRepo.create({
59+
namespaceId,
60+
userId,
61+
name: filename,
62+
mimetype,
63+
}),
64+
);
65+
}
66+
67+
async getFile(namespaceId: string, fileId: string): Promise<File | null> {
68+
return await this.fileRepo.findOne({ where: { namespaceId, id: fileId } });
69+
}
70+
71+
async generateUploadUrl(fileId: string): Promise<string> {
72+
const fileUrl = new URL(fileId, this.s3Url);
73+
fileUrl.searchParams.set('X-Amz-Expires', '900'); // 900 seconds
74+
const signedReq = await this.awsClient.sign(fileUrl.toString(), {
75+
method: 'PUT',
76+
aws: {
77+
service: 's3',
78+
signQuery: true,
79+
},
80+
});
81+
return signedReq.url;
82+
}
83+
84+
private async generateDownloadUrl(
85+
namespaceId: string,
86+
fileId: string,
87+
s3Url: URL,
88+
): Promise<string> {
89+
const file = await this.getFile(namespaceId, fileId);
90+
if (!file) {
91+
const message = this.i18n.t('resource.errors.fileNotFound');
92+
throw new AppException(message, 'FILE_NOT_FOUND', HttpStatus.NOT_FOUND);
93+
}
94+
95+
const fileUrl = new URL(fileId, s3Url);
96+
fileUrl.searchParams.set('X-Amz-Expires', '900'); // 900 seconds
97+
fileUrl.searchParams.set(
98+
'response-content-disposition',
99+
`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
100+
);
101+
102+
const signedReq = await this.awsClient.sign(fileUrl.toString(), {
103+
method: 'GET',
104+
aws: {
105+
service: 's3',
106+
signQuery: true,
107+
},
108+
});
109+
return signedReq.url;
110+
}
111+
112+
async generatePublicDownloadUrl(
113+
namespaceId: string,
114+
fileId: string,
115+
): Promise<string> {
116+
return this.generateDownloadUrl(namespaceId, fileId, this.s3Url);
117+
}
118+
119+
async generateInternalDownloadUrl(
120+
namespaceId: string,
121+
fileId: string,
122+
): Promise<string> {
123+
return this.generateDownloadUrl(namespaceId, fileId, this.s3InternalUrl);
124+
}
125+
}

src/i18n/en/resource.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
"resourceNotFile": "Resource is not a file",
44
"parentOrResourceIdRequired": "parent_id or resource_id is required",
55
"resourceNotFound": "Resource not found",
6+
"fileNotFound": "File not found",
67
"cannotDeleteRoot": "Cannot delete root resource",
78
"cannotDuplicateRoot": "Cannot duplicate root resource",
89
"cannotRestoreRoot": "Cannot restore root resource",
910
"contentRequired": "Content is required for the resource",
1011
"fileResourceNotFound": "File resource not found",
1112
"cycleDetected": "Cycle detected in the resource tree",
12-
"cannotSetParentToSubResource": "Cannot set parent to a sub-resource"
13+
"cannotSetParentToSubResource": "Cannot set parent to a sub-resource",
14+
"invalidResourceType": "Invalid resource type"
1315
}
1416
}

src/i18n/zh/resource.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
"resourceNotFile": "资源不是文件",
44
"parentOrResourceIdRequired": "需要 parent_id 或 resource_id",
55
"resourceNotFound": "资源未找到",
6+
"fileNotFound": "文件未找到",
67
"cannotDeleteRoot": "无法删除根资源",
78
"cannotDuplicateRoot": "无法复制根资源",
89
"cannotRestoreRoot": "无法恢复根资源",
910
"contentRequired": "资源需要内容",
1011
"fileResourceNotFound": "文件资源未找到",
1112
"cycleDetected": "资源树中检测到循环引用",
12-
"cannotSetParentToSubResource": "无法将父资源设置为子资源"
13+
"cannotSetParentToSubResource": "无法将父资源设置为子资源",
14+
"invalidResourceType": "资源类型无效"
1315
}
1416
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
2+
import { BaseColumns } from './base-columns';
3+
4+
export class Files1761556143000 implements MigrationInterface {
5+
public async up(queryRunner: QueryRunner): Promise<void> {
6+
const table = new Table({
7+
name: 'files',
8+
columns: [
9+
{
10+
name: 'id',
11+
type: 'uuid',
12+
isPrimary: true,
13+
default: 'uuid_generate_v4()',
14+
},
15+
{
16+
name: 'namespace_id',
17+
type: 'character varying',
18+
isNullable: false,
19+
},
20+
{
21+
name: 'user_id',
22+
type: 'uuid',
23+
isNullable: false,
24+
},
25+
{
26+
name: 'name',
27+
type: 'character varying',
28+
isNullable: false,
29+
},
30+
{
31+
name: 'mimetype',
32+
type: 'character varying',
33+
isNullable: false,
34+
},
35+
...BaseColumns(),
36+
],
37+
foreignKeys: [
38+
{
39+
columnNames: ['namespace_id'],
40+
referencedTableName: 'namespaces',
41+
referencedColumnNames: ['id'],
42+
},
43+
{
44+
columnNames: ['user_id'],
45+
referencedTableName: 'users',
46+
referencedColumnNames: ['id'],
47+
},
48+
],
49+
});
50+
await queryRunner.createTable(table, true, true, true);
51+
}
52+
53+
public down(): Promise<void> {
54+
throw new Error('Not supported.');
55+
}
56+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {
2+
MigrationInterface,
3+
QueryRunner,
4+
TableColumn,
5+
TableForeignKey,
6+
} from 'typeorm';
7+
8+
export class AddFileIdToResources1761726974942 implements MigrationInterface {
9+
public async up(queryRunner: QueryRunner): Promise<void> {
10+
await queryRunner.addColumn(
11+
'resources',
12+
new TableColumn({
13+
name: 'file_id',
14+
type: 'uuid',
15+
isNullable: true,
16+
}),
17+
);
18+
19+
await queryRunner.createForeignKey(
20+
'resources',
21+
new TableForeignKey({
22+
columnNames: ['file_id'],
23+
referencedTableName: 'files',
24+
referencedColumnNames: ['id'],
25+
}),
26+
);
27+
}
28+
29+
public down(): Promise<void> {
30+
throw new Error('Not supported.');
31+
}
32+
}

0 commit comments

Comments
 (0)