Skip to content

Commit

Permalink
Merge pull request #213 from line/dev
Browse files Browse the repository at this point in the history
release: 5.2410.45
  • Loading branch information
h4l-yup authored Mar 8, 2024
2 parents f275863 + a112799 commit 3ac723f
Show file tree
Hide file tree
Showing 147 changed files with 3,339 additions and 1,216 deletions.
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@
],
"typescript.enablePromptUseWorkspaceTsdk": true,
"typescript.tsdk": "node_modules/typescript/lib",
"css.lint.unknownAtRules": "ignore"
"css.lint.unknownAtRules": "ignore",
"files.associations": {
"*.css": "tailwindcss"
}
}
33 changes: 33 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ABC User Feedback Guide

## Image Storage Integration

ABC User Feedback supports the integration of image storage solutions to handle images submitted as part of user feedback. We currently support AWS S3 and S3-compatible storage services.

### Uploading Images

There are two methods for uploading images associated with feedback:

1. **Multipart Upload API**: This method requires setting up the [image configuration](#configuration). Once configured, you can use the multipart upload API to securely upload images directly to your storage service.

2. **Feedback Creation API with Image URLs**: Alternatively, users can submit feedback with image URLs. This method does not require the image configuration setup; however, the image URLs must come from the whitelisted domains.

**Note**: For detailed instructions on using these methods, please refer to the API documentation. You can see the documentation by accessing to `{API server host}/docs` or `{API server host}/docs/redoc`.

### Configuration

To enable image uploads directly to the server, you must configure the image storage settings. The service uses the following configuration parameters and you can set them in the setting menu.

- `accessKeyId`: Your storage service access key ID.
- `secretAccessKey`: Your storage service secret access key.
- `endpoint`: The endpoint URL for the storage service.
- `region`: The region your storage service is located in.
- `bucket`: The name of the bucket where images will be stored.

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.

### Domain Whitelist

Users can specify a whitelist of domains for image URLs. This ensures that only images from trusted sources are accepted and managed by User Feedback API server.

**Note**: The domain whitelist is enforced at the time of posting feedback with images. This means that validation against the whitelist occurs only during the submission of new feedback. Once an image URL has been uploaded to the database and accepted, it will be accessible through the web admin interface regardless of its current status on the whitelist. It is important to ensure that image URLs are from trusted sources before they are uploaded, as subsequent changes to the whitelist will not retroactively affect previously stored image URLs.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ ABC User Feedback provides the following features:
![role management image](./assets/05-role-management.png)
![dashboard image](./assets/06-dashboard.png)


## Getting Started

The frontend is built with NextJS and the backend is built with NestJS. We provide Docker images for fast and easy setup.
Expand Down Expand Up @@ -114,6 +113,10 @@ yarn turbo run dev --filter=web
yarn turbo run dev --filter=api
```

### ADMIN WEB GUIDE

For detailed information on using the admin web interface, please refer to our [Admin Web Guide](./GUIDE.md).

### Build Docker Image

For your code build, you can build docker image using docker-compose. Please refer to [remote caching](https://turbo.build/repo/docs/core-concepts/remote-caching) and [deploying with docker](https://turbo.build/repo/docs/handbook/deploying-with-docker) using `turborepo`.
Expand Down Expand Up @@ -151,4 +154,3 @@ under the License.
```

See [LICENSE](./LICENSE) for more details.

8 changes: 5 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
},
"prettier": "@ufb/prettier-config",
"dependencies": {
"@aws-sdk/client-s3": "^3.465.0",
"@aws-sdk/s3-presigned-post": "^3.465.0",
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
"@fastify/multipart": "^8.1.0",
"@fastify/static": "^6.11.2",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/axios": "^3.0.1",
Expand All @@ -51,8 +52,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"exceljs": "^4.4.0",
"fast-csv": "^5.0.0",
"fast-csv": "^5.0.1",
"joi": "^17.11.0",
"magic-bytes.js": "^1.8.0",
"mysql2": "^3.6.2",
"nestjs-cls": "^3.6.0",
"nestjs-pino": "^3.5.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/common/enums/field-format.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export enum FieldFormatEnum {
select = 'select',
multiSelect = 'multiSelect',
date = 'date',
image = 'image',
images = 'images',
}

export function isSelectFieldFormat(type: FieldFormatEnum) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Copyright 2023 LINE Corporation
*
* LINE Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class ChangeFieldFormatName1707979877290 implements MigrationInterface {
name = 'ChangeFieldFormatName1707979877290';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'images') NOT NULL`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
);
}
}
28 changes: 26 additions & 2 deletions apps/api/src/domains/admin/channel/channel/channel.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ import { CreateChannelDto } from './dtos';
import {
CreateChannelRequestDto,
FindChannelsByProjectIdRequestDto,
ImageUploadUrlTestRequestDto,
UpdateChannelFieldsRequestDto,
UpdateChannelRequestDto,
} from './dtos/requests';
import {
CreateChannelResponseDto,
FindChannelByIdResponseDto,
FindChannelsByProjectIdResponseDto,
ImageUploadUrlTestResponseDto,
} from './dtos/responses';

@ApiTags('channel')
Expand Down Expand Up @@ -105,7 +107,7 @@ export class ChannelController {

@ApiParam({ name: 'projectId', type: Number })
@RequirePermission(PermissionEnum.channel_update)
@Put('/channels/:channelId')
@Put('/:channelId')
async updateOne(
@Param('channelId', ParseIntPipe) channelId: number,
@Body() body: UpdateChannelRequestDto,
Expand All @@ -115,7 +117,7 @@ export class ChannelController {

@ApiParam({ name: 'projectId', type: Number })
@RequirePermission(PermissionEnum.channel_field_update)
@Put('/channels/:channelId/fields')
@Put('/:channelId/fields')
async updateFields(
@Param('channelId', ParseIntPipe) channelId: number,
@Body() body: UpdateChannelFieldsRequestDto,
Expand All @@ -129,4 +131,26 @@ export class ChannelController {
async delete(@Param('channelId', ParseIntPipe) channelId: number) {
await this.channelService.deleteById(channelId);
}

@ApiParam({ name: 'projectId', type: Number })
@ApiOkResponse({ type: ImageUploadUrlTestResponseDto })
@Post('/image-upload-url-test')
async getImageUploadUrlTest(
@Body()
{
accessKeyId,
secretAccessKey,
endpoint,
region,
}: ImageUploadUrlTestRequestDto,
) {
return {
success: await this.channelService.isValidImageConfig({
accessKeyId,
secretAccessKey,
endpoint,
region,
}),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface ImageConfig {
endpoint: string;
region: string;
bucket: string;
domainWhiteList: string[];
}

@Entity('channels')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,13 @@ export class ChannelMySQLService {
channel.name = name;
channel.description = description;
channel.imageConfig = imageConfig;
await this.repository.save(channel);
return await this.repository.save(channel);
}

@Transactional()
async delete(channelId: number) {
const channel = new ChannelEntity();
channel.id = channelId;
await this.repository.remove(channel);
return await this.repository.remove(channel);
}
}
20 changes: 7 additions & 13 deletions apps/api/src/domains/admin/channel/channel/channel.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,13 @@ describe('ChannelService', () => {
const dto = new UpdateChannelDto();
dto.name = faker.string.sample();
dto.description = faker.string.sample();
jest
.spyOn(ChannelMySQLService.prototype, 'findById')
.mockResolvedValue(channelFixture);
jest.spyOn(channelRepo, 'findOne').mockResolvedValue(null);
jest.spyOn(channelRepo, 'findOne').mockResolvedValueOnce(channelFixture);
jest.spyOn(channelRepo, 'findOne').mockResolvedValueOnce(null);

await channelService.updateInfo(channelId, dto);
const channel = await channelService.updateInfo(channelId, dto);

expect(channelRepo.save).toBeCalledTimes(1);
expect(channelRepo.save).toHaveBeenCalledWith({
...channelFixture,
...dto,
});
expect(channel.name).toEqual(dto.name);
expect(channel.description).toEqual(dto.description);
});
it('updating fails with a duplicate channel name', async () => {
const channelId = channelFixture.id;
Expand All @@ -166,10 +161,9 @@ describe('ChannelService', () => {
const channel = new ChannelEntity();
channel.id = channelId;

await channelService.deleteById(channelId);
const deletedChannel = await channelService.deleteById(channelId);

expect(channelRepo.remove).toBeCalledTimes(1);
expect(channelRepo.remove).toHaveBeenCalledWith(channel);
expect(deletedChannel.id).toEqual(channel.id);
});
});
});
61 changes: 59 additions & 2 deletions apps/api/src/domains/admin/channel/channel/channel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,22 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
import {
ListBucketsCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Transactional } from 'typeorm-transactional';

import { OpensearchRepository } from '@/common/repositories';
import { ProjectService } from '@/domains/admin/project/project/project.service';
import type {
CreateImageUploadUrlDto,
ImageUploadUrlTestDto,
} from '../../feedback/dtos';
import { FieldService } from '../field/field.service';
import { ChannelMySQLService } from './channel.mysql.service';
import type {
Expand Down Expand Up @@ -72,7 +82,7 @@ export class ChannelService {

@Transactional()
async updateInfo(channelId: number, dto: UpdateChannelDto) {
await this.channelMySQLService.update(channelId, dto);
return await this.channelMySQLService.update(channelId, dto);
}

@Transactional()
Expand All @@ -89,6 +99,53 @@ export class ChannelService {
await this.osRepository.deleteIndex(channelId.toString());
}

await this.channelMySQLService.delete(channelId);
return await this.channelMySQLService.delete(channelId);
}

async createImageUploadUrl(dto: CreateImageUploadUrlDto) {
const {
projectId,
channelId,
accessKeyId,
secretAccessKey,
endpoint,
region,
bucket,
extension,
} = dto;

const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint,
region,
});

const command = new PutObjectCommand({
Bucket: bucket,
Key: `${projectId}_${channelId}_${Date.now()}.${extension}`,
ContentType: 'image/*',
ACL: 'public-read',
});

return await getSignedUrl(s3, command, { expiresIn: 60 * 60 });
}

async isValidImageConfig(dto: ImageUploadUrlTestDto) {
const { accessKeyId, secretAccessKey, endpoint, region } = dto;

const s3 = new S3Client({
credentials: { accessKeyId, secretAccessKey },
endpoint,
region,
});

const command = new ListBucketsCommand({});

try {
await s3.send(command);
return true;
} catch {
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export class ImageConfigDto {
@Expose()
bucket: string;

@Expose()
domainWhiteList: string[];

public static from(params: any): ImageConfigDto {
return plainToInstance(ImageConfigDto, params, {
excludeExtraneousValues: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ export class ImageConfigRequestDto {
@ApiProperty()
@IsString()
bucket: string;

@ApiProperty({ nullable: true })
@IsString({ each: true })
domainWhiteList: string[];
}
Loading

0 comments on commit 3ac723f

Please sign in to comment.