Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.cotato.itda.domain.chat.controller;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.cotato.itda.domain.chat.service.ChatAttachmentPresignService;
import com.cotato.itda.domain.image.service.S3Service;
import com.cotato.itda.global.security.jwt.principal.JwtPrincipal;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
@RequestMapping("api/chat/attachments")
public class ChatAttachmentController {

private final ChatAttachmentPresignService chatAttachmentPresignService;

@GetMapping("/presigned-get")
public S3Service.PresignedGetUrlResponse presignedGet(
@RequestParam String objectKey,
@AuthenticationPrincipal JwtPrincipal principal
) {
Long memberId = principal.memberId();
return chatAttachmentPresignService.presignGet(memberId, objectKey);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,37 @@
package com.cotato.itda.domain.chat.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.cotato.itda.domain.chat.entity.ChatMessageAttachment;

public interface ChatMessageAttachmentRepository extends JpaRepository<ChatMessageAttachment,Long> {
interface AttachmentAccessView {
Long getAttachmentId();
Long getRoomId();
Long getMessageId();
Long getMessageSeq();
String getObjectKey();
String getMimeType();
Long getSizeBytes();
}

@Query(value = """
select
a.id as attachmentId,
m.chat_room_id as roomId,
m.id as messageId,
m.message_seq as messageSeq,
a.object_key as objectKey,
a.mime_type as mimeType,
a.size_bytes as sizeBytes
from chat_message_attachment a
join chat_message m on m.id = a.chat_message_id
where a.object_key = :objectKey
""", nativeQuery = true)
Optional<AttachmentAccessView> findAccessViewByObjectKey(@Param("objectKey") String objectKey);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.cotato.itda.domain.chat.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.cotato.itda.domain.chat.entity.ChatRoomMember;
import com.cotato.itda.domain.chat.enums.MemberRoomStatus;
import com.cotato.itda.domain.chat.exception.code.ChatErrorCode;
import com.cotato.itda.domain.chat.repository.ChatMessageAttachmentRepository;
import com.cotato.itda.domain.chat.repository.ChatRoomMemberRepository;
import com.cotato.itda.domain.image.service.S3Service;
import com.cotato.itda.global.error.exception.BusinessException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatAttachmentPresignService {

private final ChatMessageAttachmentRepository chatMessageAttachmentRepository;
private final ChatRoomMemberRepository chatRoomMemberRepository;
private final S3Service s3Service;

@Transactional(readOnly = true)
public S3Service.PresignedGetUrlResponse presignGet(Long memberId, String objectKey) {

log.info("[PRESIGNED_GET][IN] memberId={}, objectKey={}", memberId, objectKey);

objectKey = objectKey == null ? null : objectKey.trim();

// 1) objectKey로 attachment가 DB에 존재하는지 확인 + roomId/messageSeq 가져오기
var view = chatMessageAttachmentRepository.findAccessViewByObjectKey(objectKey)
.orElseThrow(() -> new BusinessException(ChatErrorCode.INVALID_ATTACHMENT_OBJECT_KEY));

log.info("[PRESIGNED_GET][CHECK] attachment found for objectKey={}, roomId={}, messageSeq={}",
objectKey, view.getRoomId(), view.getMessageSeq());
// 2) memberId가 그 roomId의 ACTIVE 멤버인지 확인
ChatRoomMember membership = chatRoomMemberRepository
.findByRoomIdAndMemberId(view.getRoomId(), memberId)
.orElseThrow(() -> new BusinessException(ChatErrorCode.CHAT_ROOM_MEMBER_CREATE_FORBIDDEN));

log.info("[PRESIGNED_GET][CHECK] membership found for memberId={}, roomId={}, status={}",
memberId, view.getRoomId(), membership.getStatus());

if (membership.getStatus() != MemberRoomStatus.ACTIVE) {
throw new BusinessException(ChatErrorCode.CHAT_ROOM_MEMBER_CREATE_FORBIDDEN);
}
log.info("[PRESIGNED_GET][CHECK] membership status is ACTIVE for memberId={}, roomId={}", memberId, view.getRoomId());

// 3) 재입장 정책
Long joinSeq = membership.getJoinSeq();
if (joinSeq != null && view.getMessageSeq() < joinSeq) {
throw new BusinessException(ChatErrorCode.CHAT_ROOM_MEMBER_CREATE_FORBIDDEN);
}
// 4) 통과하면 S3 presigned GET 발급
return s3Service.getPresignedGetUrl(objectKey);
}
}
49 changes: 49 additions & 0 deletions src/main/java/com/cotato/itda/domain/image/service/S3Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;

Expand Down Expand Up @@ -146,4 +149,50 @@ public void validateImageExists(String imageUrl) {
}
}

public PresignedGetUrlResponse getPresignedGetUrl(String objectKey) {

if (objectKey == null || objectKey.isBlank()) {
throw new BusinessException(ImageErrorCode.URL_NOT_VALID, Map.of("objectKey", objectKey));
}

// URL 전체가 들어오는 실수 방지
if (objectKey.startsWith("http://") || objectKey.startsWith("https://")) {
throw new BusinessException(ImageErrorCode.URL_NOT_VALID, Map.of("objectKey", objectKey));
}

// 경로탈출 방지
if (objectKey.contains("..") || objectKey.contains("\\") || objectKey.startsWith("/")) {
throw new BusinessException(ImageErrorCode.URL_NOT_VALID, Map.of("objectKey", objectKey));
}

Duration expires = Duration.ofMinutes(10);
Instant expiresAt = Instant.now().plus(expires);

GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(objectKey)
.build();

GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(expires)
.getObjectRequest(getObjectRequest)
.build();

PresignedGetObjectRequest presigned = s3Presigner.presignGetObject(presignRequest);

return new PresignedGetUrlResponse(
objectKey,
presigned.url().toString(),
expires.toSeconds(),
expiresAt.toString()
);
}

public record PresignedGetUrlResponse(
String objectKey,
String url,
long expiresInSeconds,
String expiresAt
) {}

}