해결:
Bucket owner enforced.해결(완화):
users/{uuid}/avatar/..., review/{date}/... 처럼 소유자/용도별 prefix.PATCH /users/me 등에서 전달된 profileImgKey가 내 prefix로 시작하는지 확인.image/jpeg/png/webp) + (선택) 업로드 후 HEAD 검사/AV 스캔/리사이즈.해결:
해결:
해결:
해결:
{
"Effect": "Allow",
"Action": ["s3:GetObject","s3:PutObject","s3:DeleteObject"],
"Resource": [
"arn:aws:s3:::<BUCKET>/users/*",
"arn:aws:s3:::<BUCKET>/review/*"
]
}
POST /users/me/avatar/presigned → PATCH /users/me/avatar → GET /users/me/avatar.해결:
해결:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::<BUCKET>","arn:aws:s3:::<BUCKET>/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
}

// dto/profile.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
export class ProfileImagePresignedDTO {
@ApiProperty({
description: '사용자가 업로드하려는 원본 파일명',
example: 'profile.png',
})
@IsString()
@IsNotEmpty()
originalName: string;
@ApiProperty({
description: '콘텐츠 타입(MIME). 허용되는 이미지 타입만 전달',
enum: ['image/jpeg', 'image/png', 'image/webp'],
example: 'image/png',
})
@IsString()
@IsIn(['image/jpeg', 'image/png', 'image/webp'])
contentType!: string;
}
export class PreSignedURLResponseDto {
@ApiProperty({ description: 'DB에 저장할 S3 Key', example: 'users/<uuid>/avatar/20250818/xxxx.webp' })
key!: string;
@ApiProperty({ description: 'S3로 직접 PUT할 Presigned URL', example: 'https://...X-Amz-Expires=300...' })
url!: string;
@ApiProperty({ description: '유효기간(초)', example: 300 })
expiresIn!: number;
constructor(args: { key: string; url: string; expiresIn: number }) {
Object.assign(this, args);
}
}
export class PresignedUserProfileImgResponseDto {
@ApiPropertyOptional({ description: '프로필 이미지(표시용) Presigned GET URL', example: 'https://...X-Amz-Expires=300...' })
profileImg?: string | null;
@ApiProperty({ description: '유효기간(초)', example: 300 })
expiresIn!: number;
constructor(profileImg: string | null, expiresIn: number) {
this.profileImg = profileImg;
this.expiresIn = expiresIn;
}
}
export class UpdateProfileDto {
@ApiPropertyOptional({ example: '소소한유저' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@Matches(/^[^\\x00-\\x1F\\x7F]*$/, { message: '제어 문자를 포함할 수 없습니다.' })
@Matches(/^(?!.*(<script|<\\/script>|<iframe|on\\w+=|javascript:|eval\\()).*$/i, { message: '스크립트 또는 악성 코드를 포함할 수 없습니다.' })
@Matches(/^[A-Za-z0-9가-힣 _-]{2,20}$/, { message: '닉네임은 2~20자' })
nickName?: string | null;
@ApiPropertyOptional({ description: '업로드 완료된 S3 Key', example: 'users/<uuid>/avatar/20250818/xxxx.webp' })
@IsOptional()
profileImgKey?: string | null;
}
// aws/aws.service.ts
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
const ALLOWED_IMAGE_MIME = ['image/jpeg','image/png','image/webp']; // Common 폴더에서 관리
@Injectable()
export class AwsService {
private readonly s3: S3Client;
private readonly bucket: string;
private readonly region: string;
constructor(private readonly config: ConfigService) {
this.region = this.config.get<string>('AWS_REGION')!;
this.bucket = this.config.get<string>('AWS_BUCKET_NAME')!;
this.s3 = new S3Client({
region: this.region,
credentials: {
accessKeyId: this.config.get<string>('AWS_ACCESS_KEY')!,
secretAccessKey: this.config.get<string>('AWS_SECRET_ACCESS_KEY')!,
},
});
}
/** 프로필 이미지 전용 Key 생성 */
private generateReviewKey(originalName: string) {
const safe = originalName.replace(/[^\w.\-]/g, '_');
const yyyy = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `review/${yyyy}/${Date.now()}-${safe}`;
}
/** PUT용 Presigned (키 지정) */
async getPresignedPutUrlByKey(key: string, contentType: string, ttlSec = 300) {
if (!ALLOWED_IMAGE_MIME.includes(contentType)) {
throw new BadRequestException(`Unsupported contentType: ${contentType}`);
}
const cmd = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(this.s3, cmd, { expiresIn: ttlSec });
return { key, url, expiresIn: ttlSec };
}
/** GET용 Presigned (표시) */
async getPresignedGetUrlByKey(key: string, ttlSec = 300, disposition: string = 'inline') {
const cmd = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
ResponseContentDisposition: disposition,
});
return getSignedUrl(this.s3, cmd, { expiresIn: ttlSec });
}
/** 존재 확인(선택) */
async assertObjectExists(key: string) {
try {
await this.s3.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
} catch {
throw new NotFoundException('S3 object not found');
}
}
async deleteByKey(key: string) {
await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
}
// users/users.service.ts
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { AwsService } from '../aws/aws.service';
import { ProfileImagePresignRequestDto, PreSignedURLResponseDto, PresignedUserProfileImgResponseDto, UpdateProfileDto } from './dto/profile.dto';
@Injectable()
export class UsersService {
constructor(
private readonly aws: AwsService,
// private readonly userRepository: UserRepository, // 실제 구현 주입
) {}
/** 업로드용 Presigned 발급 */
async createProfileImagePresignedUrl(profileImagePresignedDTO: ProfileImagePresignedDTO, uuid: string) {
const { originalName, contentType } = profileImagePresignedDTO;
const result = await this.awsService.getPresignedPutUrl(originalName, contentType);
return new PreSignedURLResponsesDTO(result);
}
/** 프로필 정보 업데이트(닉네임/프로필 이미지 키) */
async updateUserProfile(update: UpdateProfileDto, uuid: string) {
const { nickName, profileImgKey } = update;
if (nickName) {
const exists = await this.userRepository.findUserByNickName(nickName);
if (exists) throw new ConflictException('Nickname already exists.');
const r = await this.userRepository.updateNickName(uuid, nickName);
if (r.affected === 0) throw new NotFoundException('Fail update nickname');
}
if (profileImgKey) {
// (선택) 실제 존재 확인
await this.aws.assertObjectExists(profileImgKey);
const updateUrl = await this.userRepository.updateUserPhotoUrl(uuid, profileImgKey);
if (updateUrl.affected == 0) throw new NotFoundException('Fail update profileImg');
}
}
/** 표시용 Presigned GET */
async getPresignedURLUserProfileImg(uuid: string) {
const user = await this.userRepository.findUserByUUID(uuid);
if (!user) throw new NotFoundException('Not Found User');
const expiresIn = 300;
const url = await this.aws.getPresignedGetUrlByKey(user.photoUrl, expiresIn, 'inline');
return new PresignedUserProfileImgResponseDto(url, expiresIn);
}
}
// users/users.controller.ts
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiExtraModels, ApiOkResponse, ApiOperation, getSchemaPath } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { GetUUID } from '../common/decorators/get-uuid.decorator';
import { UsersService } from './users.service';
import { ProfileImagePresignRequestDto, PreSignedURLResponseDto, PresignedUserProfileImgResponseDto, UpdateProfileDto } from './dto/profile.dto';
import { SuccessResponseDTO, SuccessNoResultResponseDTO } from '../common/response/response.dto';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Patch('/me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: '프로필 정보 수정(닉네임/프로필 이미지 키)' })
@ApiOkResponse({ description: '프로필 정보 수정 성공', type: SuccessNoResultResponseDTO })
async updateProfile(@GetUUID() uuid: string, @Body() dto: UpdateProfileDto) {
await this.users.updateUserProfile(dto, uuid);
return new SuccessNoResultResponseDTO();
}
@Post('/me/avatar/presigned')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiExtraModels(SuccessResponseDTO, PreSignedURLResponseDto)
@ApiOperation({ summary: '프로필 이미지 업로드용 Presigned(PUT) 발급' })
@ApiOkResponse({
description: 'Presigned URL 발급 성공',
schema: {
allOf: [
{ $ref: getSchemaPath(SuccessResponseDTO) },
{ properties: { result: { $ref: getSchemaPath(PreSignedURLResponseDto) } } },
],
},
})
async generateProfileImagePresignedUrl(@GetUUID() uuid: string, @Body() dto: ProfileImagePresignRequestDto) {
const result = await this.users.createProfileImagePresignedUrl(dto, uuid);
return new SuccessResponseDTO(result);
}
@Get('/me/avatar')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiExtraModels(SuccessResponseDTO, PresignedUserProfileImgResponseDto)
@ApiOperation({ summary: '프로필 이미지 보기(Presigned GET URL 반환)' })
@ApiOkResponse({
description: 'Presigned URL 발급 성공',
schema: {
allOf: [
{ $ref: getSchemaPath(SuccessResponseDTO) },
{ properties: { result: { $ref: getSchemaPath(PresignedUserProfileImgResponseDto) } } },
],
},
})
async getMyAvatar(@GetUUID() uuid: string) {
const result = await this.users.getPresignedURLUserProfileImg(uuid);
return new SuccessResponseDTO(result);
}
}
/users/me/avatar/presigned (body: { originalName,contentType })originalNamecontentTypegenerateProfileKey로 내 소유 prefix 키 생성getPresignedPutUrlByKey(key, contentType, 300)으로 PUT Presigned URL 발급{ key, url, expiresIn }url (헤더: Content-Type: <파일 MIME>, 바디: 파일)/users/me (body: { profileImgKey, nickName? })profileImgKey가 users/{uuid}/avatar/로 시작하는지 확인HeadObject로 실존 확인/users/me/avatarphotoUrl(=key) 조회getPresignedGetUrlByKey(key, 300, 'inline')으로 GET Presigned URL 발급{ profileImg: <url>, expiresIn: 300 }<img src={profileImg}>로 렌더링 (만료되면 다시 호출)