Skip to content

Commit 36a51a1

Browse files
authored
Merge pull request #1974 from line/feat/implement-presigned-url-download
feat: implement presigned url download
2 parents 9b35ce4 + d06e988 commit 36a51a1

File tree

20 files changed

+420
-109
lines changed

20 files changed

+420
-109
lines changed

GUIDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ To enable image uploads directly to the server, you must configure the image sto
2323
- `endpoint`: The endpoint URL for the storage service.
2424
- `region`: The region your storage service is located in.
2525
- `bucket`: The name of the bucket where images will be stored.
26+
- `enablePresignedUrlDownload`: Enable the setting to enhance download security by using the pre-signed URL feature supported by AWS S3.
2627

2728
Depending on your use case and the desired level of access, you may need to adjust the permissions of your S3 bucket. If your application requires that the images be publicly accessible, configure your S3 bucket's policy to allow public reads.
2829

apps/api/src/domains/admin/channel/channel/channel.controller.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* under the License.
1515
*/
1616
import {
17+
BadRequestException,
1718
Body,
1819
Controller,
1920
Delete,
@@ -30,6 +31,7 @@ import {
3031
ApiCreatedResponse,
3132
ApiOkResponse,
3233
ApiParam,
34+
ApiQuery,
3335
ApiTags,
3436
} from '@nestjs/swagger';
3537

@@ -145,6 +147,7 @@ export class ChannelController {
145147
secretAccessKey,
146148
endpoint,
147149
region,
150+
bucket,
148151
}: ImageUploadUrlTestRequestDto,
149152
) {
150153
return {
@@ -153,7 +156,46 @@ export class ChannelController {
153156
secretAccessKey,
154157
endpoint,
155158
region,
159+
bucket,
156160
}),
157161
};
158162
}
163+
164+
@ApiParam({ name: 'projectId', type: Number })
165+
@ApiParam({ name: 'channelId', type: Number })
166+
@ApiQuery({
167+
name: 'imageKey',
168+
type: String,
169+
required: true,
170+
description: 'Image Key for the pre-signed url download',
171+
example: 'test-image-key.jpg',
172+
})
173+
@ApiOkResponse({ type: String })
174+
@UseGuards(JwtAuthGuard)
175+
@Get('/:channelId/image-download-url')
176+
async getImageDownloadUrl(
177+
@Param('projectId', ParseIntPipe) projectId: number,
178+
@Param('channelId', ParseIntPipe) channelId: number,
179+
@Query('imageKey') imageKey: string,
180+
) {
181+
if (!imageKey) {
182+
throw new BadRequestException('imageKey is required in query parameter');
183+
}
184+
const channel = await this.channelService.findById({ channelId });
185+
if (channel.project.id !== projectId) {
186+
throw new BadRequestException('Invalid channel id');
187+
}
188+
if (!channel.imageConfig) {
189+
throw new BadRequestException('No image config in this channel');
190+
}
191+
192+
return await this.channelService.createImageDownloadUrl({
193+
accessKeyId: channel.imageConfig.accessKeyId,
194+
secretAccessKey: channel.imageConfig.secretAccessKey,
195+
endpoint: channel.imageConfig.endpoint,
196+
region: channel.imageConfig.region,
197+
bucket: channel.imageConfig.bucket,
198+
imageKey,
199+
});
200+
}
159201
}

apps/api/src/domains/admin/channel/channel/channel.service.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* under the License.
1515
*/
1616
import {
17-
ListBucketsCommand,
17+
GetObjectCommand,
18+
ListObjectsCommand,
1819
PutObjectCommand,
1920
S3Client,
2021
} from '@aws-sdk/client-s3';
@@ -26,6 +27,7 @@ import { Transactional } from 'typeorm-transactional';
2627
import { OpensearchRepository } from '@/common/repositories';
2728
import { ProjectService } from '@/domains/admin/project/project/project.service';
2829
import type {
30+
CreateImageDownloadUrlDto,
2931
CreateImageUploadUrlDto,
3032
ImageUploadUrlTestDto,
3133
} from '../../feedback/dtos';
@@ -130,16 +132,34 @@ export class ChannelService {
130132
return await getSignedUrl(s3, command, { expiresIn: 60 * 60 });
131133
}
132134

135+
async createImageDownloadUrl(dto: CreateImageDownloadUrlDto) {
136+
const { accessKeyId, secretAccessKey, endpoint, region, bucket, imageKey } =
137+
dto;
138+
139+
const s3 = new S3Client({
140+
credentials: { accessKeyId, secretAccessKey },
141+
endpoint,
142+
region,
143+
});
144+
145+
const command = new GetObjectCommand({
146+
Bucket: bucket,
147+
Key: imageKey,
148+
});
149+
150+
return await getSignedUrl(s3, command, { expiresIn: 60 });
151+
}
152+
133153
async isValidImageConfig(dto: ImageUploadUrlTestDto) {
134-
const { accessKeyId, secretAccessKey, endpoint, region } = dto;
154+
const { accessKeyId, secretAccessKey, endpoint, region, bucket } = dto;
135155

136156
const s3 = new S3Client({
137157
credentials: { accessKeyId, secretAccessKey },
138158
endpoint,
139159
region,
140160
});
141161

142-
const command = new ListBucketsCommand({});
162+
const command = new ListObjectsCommand({ Bucket: bucket });
143163

144164
try {
145165
await s3.send(command);

apps/api/src/domains/admin/channel/channel/dtos/requests/image-config-request.dto.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* under the License.
1515
*/
1616
import { ApiProperty } from '@nestjs/swagger';
17-
import { IsString } from 'class-validator';
17+
import { IsBoolean, IsString } from 'class-validator';
1818

1919
export class ImageConfigRequestDto {
2020
@ApiProperty()
@@ -40,4 +40,8 @@ export class ImageConfigRequestDto {
4040
@ApiProperty({ nullable: true, type: [String] })
4141
@IsString({ each: true })
4242
domainWhiteList: string[];
43+
44+
@ApiProperty({ required: false })
45+
@IsBoolean()
46+
enablePresignedUrlDownload: boolean;
4347
}

apps/api/src/domains/admin/channel/channel/dtos/responses/find-channel-by-id-response.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class FindChannelByIdResponseDto {
3737
description: string;
3838

3939
@Expose()
40-
@ApiProperty()
40+
@ApiProperty({ required: false })
4141
imageConfig: ImageConfigResponseDto;
4242

4343
@Expose()

apps/api/src/domains/admin/channel/channel/dtos/responses/image-config-response.dto.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,8 @@ export class ImageConfigResponseDto {
4040
@Expose()
4141
@ApiProperty()
4242
domainWhiteList: string[];
43+
44+
@Expose()
45+
@ApiProperty({ required: false, type: 'boolean' })
46+
enablePresignedUrlDownload: boolean | undefined;
4347
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright 2025 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
export class CreateImageDownloadUrlDto {
18+
accessKeyId: string;
19+
secretAccessKey: string;
20+
endpoint: string;
21+
region: string;
22+
bucket: string;
23+
imageKey: string;
24+
}

apps/api/src/domains/admin/feedback/dtos/image-upload-url-test.dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ export class ImageUploadUrlTestDto {
1919
secretAccessKey: string;
2020
endpoint: string;
2121
region: string;
22+
bucket: string;
2223
}

apps/api/src/domains/admin/feedback/dtos/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
CreateFeedbackOSDto,
2020
} from './create-feedback.dto';
2121
export { CreateImageUploadUrlDto } from './create-image-upload-url.dto';
22+
export { CreateImageDownloadUrlDto } from './create-image-download-url.dto';
2223
export { FindFeedbacksByChannelIdDto } from './find-feedbacks-by-channel-id.dto';
2324
export {
2425
UpdateFeedbackDto,

apps/api/src/domains/admin/feedback/feedback.mysql.service.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -551,37 +551,46 @@ export class FeedbackMySQLService {
551551
async updateFeedback(dto: UpdateFeedbackMySQLDto) {
552552
const { feedbackId, data } = dto;
553553

554+
let query = `JSON_SET(IFNULL(feedbacks.data,'{}'), `;
555+
const parameters: Record<string, any> = {};
556+
557+
if (Object.keys(data).length === 0) {
558+
query = 'data';
559+
} else {
560+
Object.entries(data).forEach(([fieldKey, value], index) => {
561+
query += `'$.${fieldKey}', `;
562+
if (Array.isArray(value)) {
563+
const arrayParams = value
564+
.map((v, i) => {
565+
const paramName = `value${index}_${i}`;
566+
parameters[paramName] = v as string | number;
567+
return `:${paramName}`;
568+
})
569+
.join(', ');
570+
query += `JSON_ARRAY(${arrayParams})`;
571+
} else {
572+
const paramName = `value${index}`;
573+
parameters[paramName] = value as string | number;
574+
query += `:${paramName}`;
575+
}
576+
577+
if (index + 1 !== Object.entries(data).length) {
578+
query += ', ';
579+
}
580+
});
581+
582+
query += ')';
583+
}
584+
554585
await this.feedbackRepository
555586
.createQueryBuilder('feedbacks')
556587
.update('feedbacks')
557588
.set({
558-
data: () => {
559-
if (Object.keys(data).length === 0) {
560-
return 'data';
561-
}
562-
let query = `JSON_SET(IFNULL(feedbacks.data,'{}'), `;
563-
for (const [index, fieldKey] of Object.entries(Object.keys(data))) {
564-
query += `'$.${fieldKey}',
565-
${
566-
Array.isArray(data[fieldKey]) ?
567-
data[fieldKey].length === 0 ?
568-
'JSON_ARRAY()'
569-
: 'JSON_ARRAY("' + data[fieldKey].join('","') + '")'
570-
: `:${fieldKey}`
571-
}`;
572-
573-
if (parseInt(index) + 1 !== Object.entries(data).length) {
574-
query += ',';
575-
}
576-
}
577-
query += `)`;
578-
579-
return query;
580-
},
589+
data: () => query,
581590
updatedAt: () => `'${DateTime.utc().toFormat('yyyy-MM-dd HH:mm:ss')}'`,
582591
})
583592
.where('id = :feedbackId', { feedbackId })
584-
.setParameters(data)
593+
.setParameters(parameters)
585594
.execute();
586595
}
587596

0 commit comments

Comments
 (0)