Skip to content

Commit 840482d

Browse files
committed
refactor(s3): simplify s3 service
1 parent d3c27ba commit 840482d

File tree

7 files changed

+96
-141
lines changed

7 files changed

+96
-141
lines changed

src/attachments/attachments.service.ts

Lines changed: 54 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,19 @@ import {
22
encodeFileName,
33
getOriginalFileName,
44
} from 'omniboxd/utils/encode-filename';
5-
import { Injectable, Logger, HttpStatus } from '@nestjs/common';
6-
import { AppException } from 'omniboxd/common/exceptions/app.exception';
7-
import { I18nService } from 'nestjs-i18n';
5+
import { Injectable, Logger } from '@nestjs/common';
86
import { Response } from 'express';
9-
import { S3Service } from 'omniboxd/s3/s3.service';
7+
import { ObjectMeta, S3Service } from 'omniboxd/s3/s3.service';
108
import { PermissionsService } from 'omniboxd/permissions/permissions.service';
119
import { ResourcePermission } from 'omniboxd/permissions/resource-permission.enum';
12-
import { objectStreamResponse } from 'omniboxd/s3/utils';
1310
import { ResourceAttachmentsService } from 'omniboxd/resource-attachments/resource-attachments.service';
1411
import {
1512
UploadAttachmentsResponseDto,
1613
UploadedAttachmentDto,
1714
} from './dto/upload-attachments-response.dto';
18-
import { SharesService } from 'omniboxd/shares/shares.service';
1915
import { SharedResourcesService } from 'omniboxd/shared-resources/shared-resources.service';
2016
import { Share } from 'omniboxd/shares/entities/share.entity';
17+
import { Readable } from 'stream';
2118

2219
@Injectable()
2320
export class AttachmentsService {
@@ -27,31 +24,53 @@ export class AttachmentsService {
2724
private readonly s3Service: S3Service,
2825
private readonly permissionsService: PermissionsService,
2926
private readonly resourceAttachmentsService: ResourceAttachmentsService,
30-
private readonly sharesService: SharesService,
3127
private readonly sharedResourcesService: SharedResourcesService,
32-
private readonly i18n: I18nService,
3328
) {}
3429

35-
async checkPermission(
36-
namespaceId: string,
37-
resourceId: string,
38-
userId: string,
39-
permission: ResourcePermission = ResourcePermission.CAN_VIEW,
40-
) {
41-
const hasPermission = await this.permissionsService.userHasPermission(
42-
namespaceId,
43-
resourceId,
44-
userId,
45-
permission,
46-
);
47-
if (!hasPermission) {
48-
const message = this.i18n.t('auth.errors.notAuthorized');
49-
throw new AppException(message, 'NOT_AUTHORIZED', HttpStatus.FORBIDDEN);
30+
private s3Path(attachmentId: string): string {
31+
return `attachments/${attachmentId}`;
32+
}
33+
34+
private isMedia(mimetype?: string): boolean {
35+
for (const type of ['image/', 'audio/']) {
36+
if (mimetype?.startsWith(type)) {
37+
return true;
38+
}
5039
}
40+
return false;
5141
}
5242

53-
s3Path(attachmentId: string): string {
54-
return `attachments/${attachmentId}`;
43+
private objectStreamResponse(
44+
objectStream: Readable,
45+
objectMeta: ObjectMeta,
46+
httpResponse: Response,
47+
cacheControl: boolean = true,
48+
forceDownload: boolean = true,
49+
) {
50+
const headers: Record<string, string> = {};
51+
if (objectMeta.metadata?.filename) {
52+
const disposition = forceDownload ? 'attachment' : 'inline';
53+
headers['Content-Disposition'] =
54+
`${disposition}; filename*=UTF-8''${encodeURIComponent(objectMeta.metadata.filename)}`;
55+
}
56+
if (objectMeta.contentType) {
57+
headers['Content-Type'] = objectMeta.contentType;
58+
}
59+
if (objectMeta.contentLength) {
60+
headers['Content-Length'] = objectMeta.contentLength.toString();
61+
}
62+
if (objectMeta.lastModified) {
63+
headers['Last-Modified'] = objectMeta.lastModified.toUTCString();
64+
}
65+
if (cacheControl) {
66+
headers['Cache-Control'] = 'public, max-age=31536000'; // 1 year
67+
} else {
68+
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
69+
}
70+
for (const [key, value] of Object.entries(headers)) {
71+
httpResponse.setHeader(key, value);
72+
}
73+
objectStream.pipe(httpResponse);
5574
}
5675

5776
async uploadAttachment(
@@ -62,7 +81,7 @@ export class AttachmentsService {
6281
buffer: Buffer,
6382
mimetype: string,
6483
) {
65-
await this.checkPermission(
84+
await this.permissionsService.userHasPermissionOrFail(
6685
namespaceId,
6786
resourceId,
6887
userId,
@@ -89,7 +108,7 @@ export class AttachmentsService {
89108
userId: string,
90109
files: Express.Multer.File[],
91110
): Promise<UploadAttachmentsResponseDto> {
92-
await this.checkPermission(
111+
await this.permissionsService.userHasPermissionOrFail(
93112
namespaceId,
94113
resourceId,
95114
userId,
@@ -135,7 +154,7 @@ export class AttachmentsService {
135154
userId: string,
136155
httpResponse: Response,
137156
) {
138-
await this.checkPermission(
157+
await this.permissionsService.userHasPermissionOrFail(
139158
namespaceId,
140159
resourceId,
141160
userId,
@@ -148,16 +167,11 @@ export class AttachmentsService {
148167
attachmentId,
149168
);
150169

151-
const objectResponse = await this.s3Service.get(
170+
const { stream, meta } = await this.s3Service.getObject(
152171
this.s3Path(attachmentId),
153172
);
154-
155-
// Display media files inline, download other files as attachments
156-
const forceDownload = !this.isMedia(objectResponse.mimetype);
157-
158-
return objectStreamResponse(objectResponse, httpResponse, {
159-
forceDownload,
160-
});
173+
const forceDownload = !this.isMedia(meta.contentType);
174+
this.objectStreamResponse(stream, meta, httpResponse, true, forceDownload);
161175
}
162176

163177
async deleteAttachment(
@@ -166,7 +180,7 @@ export class AttachmentsService {
166180
attachmentId: string,
167181
userId: string,
168182
) {
169-
await this.checkPermission(
183+
await this.permissionsService.userHasPermissionOrFail(
170184
namespaceId,
171185
resourceId,
172186
userId,
@@ -197,25 +211,10 @@ export class AttachmentsService {
197211
resourceId,
198212
attachmentId,
199213
);
200-
201-
const objectResponse = await this.s3Service.get(
214+
const { stream, meta } = await this.s3Service.getObject(
202215
this.s3Path(attachmentId),
203216
);
204-
205-
// Display media files inline, download other files as attachments
206-
const forceDownload = !this.isMedia(objectResponse.mimetype);
207-
208-
return objectStreamResponse(objectResponse, httpResponse, {
209-
forceDownload,
210-
});
211-
}
212-
213-
isMedia(mimetype: string): boolean {
214-
for (const type of ['image/', 'audio/']) {
215-
if (mimetype.startsWith(type)) {
216-
return true;
217-
}
218-
}
219-
return false;
217+
const forceDownload = !this.isMedia(meta.contentType);
218+
this.objectStreamResponse(stream, meta, httpResponse, true, forceDownload);
220219
}
221220
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -890,10 +890,10 @@ export class NamespaceResourcesService {
890890
}
891891
const artifactName = resource.id;
892892

893-
const fileStream = await this.s3Service.getObject(
893+
const { stream } = await this.s3Service.getObject(
894894
this.s3Path(artifactName),
895895
);
896-
return { fileStream, resource };
896+
return { fileStream: stream, resource };
897897
}
898898

899899
async getAllResourcesByUser(

src/permissions/permissions.service.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,24 @@ export class PermissionsService {
508508
return comparePermission(permission, requiredPermission) >= 0;
509509
}
510510

511+
async userHasPermissionOrFail(
512+
namespaceId: string,
513+
resourceId: string,
514+
userId: string,
515+
permission: ResourcePermission = ResourcePermission.CAN_VIEW,
516+
) {
517+
const hasPermission = await this.userHasPermission(
518+
namespaceId,
519+
resourceId,
520+
userId,
521+
permission,
522+
);
523+
if (!hasPermission) {
524+
const message = this.i18n.t('auth.errors.notAuthorized');
525+
throw new AppException(message, 'NOT_AUTHORIZED', HttpStatus.FORBIDDEN);
526+
}
527+
}
528+
511529
/**
512530
* Filter resources by permission.
513531
* For each non-root resource specified, it's required that all its parents are also specified.

src/s3/s3.service.ts

Lines changed: 20 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,28 @@ import {
44
S3Client,
55
PutObjectCommand,
66
GetObjectCommand,
7-
HeadObjectCommand,
87
DeleteObjectCommand,
98
HeadBucketCommand,
109
CreateBucketCommand,
1110
} from '@aws-sdk/client-s3';
1211
import { Readable } from 'stream';
1312
import generateId from 'omniboxd/utils/generate-id';
1413
import {
15-
decodeFileName,
1614
encodeFileName,
1715
getOriginalFileName,
1816
} from 'omniboxd/utils/encode-filename';
1917

2018
export interface PutOptions {
2119
id?: string;
22-
metadata?: Record<string, any>;
20+
metadata?: Record<string, string>;
2321
folder?: string;
2422
}
2523

26-
export interface ObjectInfo {
27-
filename: string;
28-
mimetype: string;
29-
metadata: Record<string, any>;
30-
stat: {
31-
size?: number;
32-
lastModified?: Date;
33-
};
34-
}
35-
36-
export interface GetResponse extends ObjectInfo {
37-
stream: Readable;
24+
export interface ObjectMeta {
25+
contentType?: string;
26+
contentLength?: number;
27+
metadata?: Record<string, string>;
28+
lastModified?: Date;
3829
}
3930

4031
@Injectable()
@@ -130,13 +121,23 @@ export class S3Service implements OnModuleInit {
130121
await this.s3Client.send(command);
131122
}
132123

133-
async getObject(key: string): Promise<Readable> {
124+
async getObject(
125+
key: string,
126+
): Promise<{ stream: Readable; meta: ObjectMeta }> {
134127
const command = new GetObjectCommand({
135128
Bucket: this.bucket,
136129
Key: key,
137130
});
138131
const response = await this.s3Client.send(command);
139-
return response.Body as Readable;
132+
return {
133+
stream: response.Body as Readable,
134+
meta: {
135+
contentType: response.ContentType,
136+
contentLength: response.ContentLength,
137+
metadata: response.Metadata,
138+
lastModified: response.LastModified,
139+
},
140+
};
140141
}
141142

142143
async deleteObject(key: string) {
@@ -153,39 +154,12 @@ export class S3Service implements OnModuleInit {
153154
mimetype: string,
154155
options?: PutOptions,
155156
): Promise<string> {
156-
const { id = this.generateId(filename), metadata = {} } = options || {};
157+
const { id = this.generateId(filename) } = options || {};
157158
const path: string = options?.folder ? `${options.folder}/${id}` : id;
158159
await this.putObject(path, buffer, mimetype, {
160+
...options?.metadata,
159161
filename: encodeFileName(filename),
160-
metadata: JSON.stringify(metadata),
161162
});
162163
return id;
163164
}
164-
165-
async info(objectName: string): Promise<ObjectInfo> {
166-
const command = new HeadObjectCommand({
167-
Bucket: this.bucket,
168-
Key: objectName,
169-
});
170-
const response = await this.s3Client.send(command);
171-
const metadataString: string =
172-
response.Metadata?.metadata_string || response.Metadata?.metadata || '{}';
173-
const encodedFilename: string = response.Metadata?.filename || '';
174-
const filename: string = decodeFileName(encodedFilename);
175-
const mimetype: string = response.ContentType || 'application/octet-stream';
176-
const metadata: Record<string, any> = JSON.parse(metadataString);
177-
const stat = {
178-
size: response.ContentLength,
179-
lastModified: response.LastModified,
180-
};
181-
return { filename, mimetype, metadata, stat };
182-
}
183-
184-
async get(objectName: string): Promise<GetResponse> {
185-
const [stream, info] = await Promise.all([
186-
this.getObject(objectName),
187-
this.info(objectName),
188-
]);
189-
return { stream, ...info } as GetResponse;
190-
}
191165
}

src/s3/utils.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/wizard/chunk-manager.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class ChunkManagerService {
4444
const buffers: Buffer[] = [];
4545
for (let i = 0; i < totalChunks; i++) {
4646
const chunkPath = this.getChunkPath(taskId, i);
47-
const stream = await this.s3Service.getObject(chunkPath);
47+
const { stream } = await this.s3Service.getObject(chunkPath);
4848
buffers.push(await buffer(stream));
4949
}
5050
return Buffer.concat(buffers).toString('utf-8');

src/wizard/wizard.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ export class WizardService {
489489
}
490490

491491
async getHtmlFromMinioGzipFile(path: string) {
492-
const stream = await this.s3Service.getObject(path);
492+
const { stream } = await this.s3Service.getObject(path);
493493
const gunzip = createGunzip();
494494
return new Promise<string>((resolve, reject) => {
495495
const chunks: Buffer[] = [];

0 commit comments

Comments
 (0)