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
Expand Up @@ -71,10 +71,12 @@ public ResponseEntity<ApiResponse<Void>> deleteFeedback(
@GetMapping("/videos/{videoId}/feedbacks")
public ResponseEntity<ApiResponse<FeedbackListResDTO>> getFeedbackList(
@PathVariable Long videoId,
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Long guestId,
@RequestParam(required = false) String cursor,
@RequestParam(required = false) Integer size
) {
FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, cursor, size);
FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, cursor, size);

return ResponseEntity
.ok(ApiResponse.success(CommonSuccessCode.OK, result));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Reply", description = "답글 API")
@Tag(name = "Feedback Reply", description = "피드백 답글 API")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
Expand All @@ -45,10 +45,12 @@ public ResponseEntity<ApiResponse<ReplyCreateResDTO>> createReply(
@GetMapping("/feedbacks/{feedbackId}/replies")
public ResponseEntity<ApiResponse<ReplyListResDTO>> getReplyList(
@PathVariable Long feedbackId,
@AuthenticationPrincipal Long userId,
@RequestParam(required = false) Long guestId,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false) Integer size
) {
ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, cursor, size);
ReplyListResDTO result = feedbackDetailService.getReplyList(feedbackId, userId, guestId, cursor, size);

return ResponseEntity
.ok(ApiResponse.success(CommonSuccessCode.OK, result));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import com.slatto.domain.feedback.repository.FeedbackDetailRepository;
import com.slatto.domain.feedback.repository.FeedbackRepository;
import com.slatto.domain.sharelink.entity.Guest;
import com.slatto.domain.sharelink.entity.ShareLink;
import com.slatto.domain.sharelink.exception.ShareLinkErrorCode;
import com.slatto.domain.sharelink.repository.GuestRepository;
import com.slatto.domain.user.entity.Users;
import com.slatto.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -59,8 +61,8 @@ public ReplyCreateResDTO createReply(Long feedbackId, Long userId, ReplyCreateRe
user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
} else {
guest = guestRepository.findById(req.guestId())
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
// 게스트: 원 피드백의 영상에 접근할 자격이 있는지 검증 후 Guest 확보
guest = validateGuestAccess(req.guestId(), feedback.getVideo().getId());
}

// 4. 저장
Expand All @@ -78,32 +80,62 @@ private void validateWriter(Long userId, Long guestId) {
}
}

// 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환
// Guest → ShareLink → Video 체인으로 소유 여부 확인
private Guest validateGuestAccess(Long guestId, Long videoId) {
Guest guest = guestRepository.findById(guestId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

ShareLink shareLink = guest.getShareLink();

// 1. 링크가 살아있는지 (활성 + 미만료)
if (!shareLink.isUsable()) {
throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE);
}

// 2. 게스트의 링크 영상 == 요청 영상인지
if (!shareLink.getVideo().getId().equals(videoId)) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}

return guest;
}

@Transactional(readOnly = true)
public ReplyListResDTO getReplyList(Long feedbackId, Long cursor, Integer size) {
public ReplyListResDTO getReplyList(Long feedbackId, Long userId, Long guestId, Long cursor, Integer size) {

// 1. 원 피드백 존재 확인
feedbackRepository.findById(feedbackId)
Feedback feedback = feedbackRepository.findById(feedbackId)
.filter(f -> f.getDeletedAt() == null)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

// 2. size 기본값 + 상한 처리
// 2. 게스트가 조회하는 경우 원 피드백의 영상에 접근 자격이 있는지 검증
// 회원이 아니면 guestId 필수 — 익명(둘 다 null) 조회 차단
if (userId == null) {
if (guestId == null) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}
validateGuestAccess(guestId, feedback.getVideo().getId());
}

// 3. size 기본값 + 상한 처리
int pageSize = (size == null || size <= 0)
? DEFAULT_PAGE_SIZE
: Math.min(size, MAX_PAGE_SIZE);
Pageable pageable = PageRequest.of(0, pageSize + 1); // hasNext 판단용 +1

// 3. 조회
// 4. 조회
List<FeedbackDetail> replies = (cursor == null)
? feedbackDetailRepository.findFirstPage(feedbackId, pageable)
: feedbackDetailRepository.findNextPage(feedbackId, cursor, pageable);

// 4. hasNext 판단 + 초과분 제거
// 5. hasNext 판단 + 초과분 제거
boolean hasNext = replies.size() > pageSize;
if (hasNext) {
replies = replies.subList(0, pageSize);
}

// 5. nextCursor
// 6. nextCursor
Long nextCursor = (hasNext && !replies.isEmpty())
? replies.getLast().getId()
: null;
Expand All @@ -122,15 +154,20 @@ public ReplyUpdateResDTO updateReply(Long replyId, Long userId, ReplyUpdateReqDT
// 2. 작성자 검증
validateWriter(userId, req.guestId());

// 3. 본인 확인
// 3. 게스트면 이 답글의 영상에 접근 자격이 있는지 검증 (답글 → 피드백 → 영상)
if (userId == null) {
validateGuestAccess(req.guestId(), reply.getFeedback().getVideo().getId());
}

// 4. 본인 확인
if (!reply.isWriter(userId, req.guestId())) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. 수정 (더티 체킹)
// 5. 수정 (더티 체킹)
reply.update(req.content());

// 5. updatedAt 갱신 반영
// 6. updatedAt 갱신 반영
feedbackDetailRepository.flush();

return feedbackDetailConverter.toUpdateResponse(reply);
Expand All @@ -147,12 +184,17 @@ public void deleteReply(Long replyId, Long userId, Long guestId) {
// 2. 작성자 검증
validateWriter(userId, guestId);

// 3. 본인 확인
// 3. 게스트면 이 답글의 영상에 접근 자격이 있는지 검증 (답글 → 피드백 → 영상)
if (userId == null) {
validateGuestAccess(guestId, reply.getFeedback().getVideo().getId());
}

// 4. 본인 확인
if (!reply.isWriter(userId, guestId)) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. soft delete (더티 체킹)
// 5. soft delete (더티 체킹)
reply.softDelete();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import com.slatto.domain.feedback.entity.Feedback;
import com.slatto.domain.feedback.repository.FeedbackRepository;
import com.slatto.domain.sharelink.entity.Guest;
import com.slatto.domain.sharelink.entity.ShareLink;
import com.slatto.domain.sharelink.exception.ShareLinkErrorCode;
import com.slatto.domain.sharelink.repository.GuestRepository;
import com.slatto.domain.user.entity.Users;
import com.slatto.domain.user.repository.UserRepository;
Expand Down Expand Up @@ -70,8 +72,8 @@ public FeedbackCreateResDTO createFeedback(Long videoId, Long userId, FeedbackCr
user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
} else {
guest = guestRepository.findById(req.guestId())
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));
// 게스트: 이 영상에 접근할 자격이 있는지 검증 후 Guest 확보
guest = validateGuestAccess(req.guestId(), videoId);
}

// 4. 저장
Expand All @@ -92,15 +94,20 @@ public FeedbackUpdateResDTO updateFeedback(Long feedbackId, Long userId, Feedbac
// 2. 작성자 검증
validateWriter(userId, req.guestId());

// 3. 본인 확인
// 3. 게스트면 이 피드백의 영상에 접근 자격이 있는지 검증
if (userId == null) {
validateGuestAccess(req.guestId(), feedback.getVideo().getId());
}

// 4. 본인 확인
if (!feedback.isWriter(userId, req.guestId())) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경)
// 5. 수정 (status 전달 안 함 — 해결 상태는 전용 API에서만 변경)
feedback.update(req.content(), req.startTime(), req.endTime());

// 5. updatedAt 갱신을 응답에 반영하기 위해 flush
// 6. updatedAt 갱신을 응답에 반영하기 위해 flush
feedbackRepository.flush();

return feedbackConverter.toUpdateResponse(feedback);
Expand All @@ -115,6 +122,27 @@ private void validateWriter(Long userId, Long guestId) {
}
}

// 게스트가 해당 영상에 접근할 자격이 있는지 검증하고, 검증된 Guest를 반환
// Guest → ShareLink → Video 체인으로 소유 여부 확인
private Guest validateGuestAccess(Long guestId, Long videoId) {
Guest guest = guestRepository.findById(guestId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

ShareLink shareLink = guest.getShareLink();

// 1. 링크가 살아있는지 (활성 + 미만료)
if (!shareLink.isUsable()) {
throw new BaseException(ShareLinkErrorCode.SHARE_LINK_UNAVAILABLE);
}

// 2. 게스트의 링크 영상 == 요청 영상인지
if (!shareLink.getVideo().getId().equals(videoId)) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}

return guest;
}

@Transactional
public void deleteFeedback(Long feedbackId, Long userId, Long guestId) {

Expand All @@ -126,17 +154,22 @@ public void deleteFeedback(Long feedbackId, Long userId, Long guestId) {
// 2. 작성자 검증
validateWriter(userId, guestId);

// 3. 본인 확인
// 3. 게스트면 이 피드백의 영상에 접근 자격이 있는지 검증
if (userId == null) {
validateGuestAccess(guestId, feedback.getVideo().getId());
}

// 4. 본인 확인
if (!feedback.isWriter(userId, guestId)) {
throw new BaseException(CommonErrorCode.FORBIDDEN);
}

// 4. soft delete (더티 체킹으로 자동 반영)
// 5. soft delete (더티 체킹으로 자동 반영)
feedback.softDelete();
}

@Transactional(readOnly = true)
public FeedbackListResDTO getFeedbackList(Long videoId, String cursor, Integer size) {
public FeedbackListResDTO getFeedbackList(Long videoId, Long userId, Long guestId, String cursor, Integer size) {

// 1. 영상 존재 확인
boolean videoExists = entityManagerProvider.getObject().createQuery("""
Expand All @@ -149,13 +182,22 @@ select count(v) from Video v where v.id = :videoId
throw new BaseException(CommonErrorCode.NOT_FOUND);
}

// 2. size 기본값 + 상한 처리
// 2. 게스트가 조회하는 경우 이 영상에 접근 자격이 있는지 검증
// 회원이 아니면 guestId 필수 — 익명(둘 다 null) 조회 차단
if (userId == null) {
if (guestId == null) {
throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
}
validateGuestAccess(guestId, videoId);
}
Comment on lines +185 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

익명 목록 조회를 차단하십시오.

SecurityConfig는 두 GET 경로를 permitAll()로 엽니다. 현재 조건은 userIdguestId가 모두 null이면 검증을 건너뜁니다. 따라서 요청자는 guestId 없이 다른 영상의 피드백과 답글 목록을 조회할 수 있습니다.

userId == null이면 guestId가 반드시 존재하도록 확인하십시오. guestId가 없으면 GUEST_ACCESS_DENIED를 발생시키십시오.

  • src/main/java/com/slatto/domain/feedback/service/FeedbackService.java#L185-L188: guestId == null인 익명 요청을 거부한 후 validateGuestAccess를 호출하십시오.
  • src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java#L112-L115: guestId == null인 익명 요청을 거부한 후 validateGuestAccess를 호출하십시오.
수정 예시
- if (userId == null && guestId != null) {
-     validateGuestAccess(guestId, videoId);
+ if (userId == null) {
+     if (guestId == null) {
+         throw new BaseException(ShareLinkErrorCode.GUEST_ACCESS_DENIED);
+     }
+     validateGuestAccess(guestId, videoId);
  }
📍 Affects 2 files
  • src/main/java/com/slatto/domain/feedback/service/FeedbackService.java#L185-L188 (this comment)
  • src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java#L112-L115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/feedback/service/FeedbackService.java` around
lines 185 - 188, 익명 목록 조회 시 guestId 없이 접근하는 경로를 차단하십시오.
src/main/java/com/slatto/domain/feedback/service/FeedbackService.java 185-188의
userId 검증에서 guestId가 null이면 GUEST_ACCESS_DENIED를 발생시키고, guestId가 있으면
validateGuestAccess를 호출하십시오. 동일하게
src/main/java/com/slatto/domain/feedback/service/FeedbackDetailService.java
112-115의 익명 접근 검증도 수정하십시오.


// 3. size 기본값 + 상한 처리
int pageSize = (size == null || size <= 0)
? DEFAULT_PAGE_SIZE
: Math.min(size, MAX_PAGE_SIZE);
Pageable pageable = PageRequest.of(0, pageSize + 1); // hasNext 판단용으로 1개 더

// 3. 커서에 따라 조회
// 4. 커서에 따라 조회
List<Feedback> feedbacks;

if (cursor == null || cursor.isBlank()) {
Expand All @@ -180,21 +222,21 @@ select count(v) from Video v where v.id = :videoId
}
}

// 4. hasNext 판단 + 초과분 제거
// 5. hasNext 판단 + 초과분 제거
boolean hasNext = feedbacks.size() > pageSize;
if (hasNext) {
feedbacks = feedbacks.subList(0, pageSize);
}

// 5. nextCursor 조립
// 6. nextCursor 조립
String nextCursor = null;
if (hasNext && !feedbacks.isEmpty()) {
Feedback last = feedbacks.getLast();
String timePart = (last.getStartTime() == null) ? "n" : String.valueOf(last.getStartTime());
nextCursor = timePart + "_" + last.getId();
}

// 6. 답글 개수 한 번에 조회
// 7. 답글 개수 한 번에 조회
Map<Long, Long> replyCountMap = new HashMap<>();

if (!feedbacks.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum ShareLinkErrorCode implements BaseCode {
SHARE_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "SHARELINK404", "공유 링크를 찾을 수 없습니다."),
SHARE_LINK_ALREADY_EXISTS(HttpStatus.CONFLICT, "SHARELINK409", "이미 이 영상의 공유 링크가 존재합니다."),
INVALID_EXPIRED_AT(HttpStatus.BAD_REQUEST, "SHARELINK400", "만료 일시는 현재 시각보다 이후여야 합니다."),
SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다.");
SHARE_LINK_UNAVAILABLE(HttpStatus.GONE, "SHARELINK410", "비활성화되었거나 만료된 링크입니다."),
GUEST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "SHARELINK403", "해당 영상에 접근 권한이 없는 게스트입니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/slatto/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
// 게스트 등록
.requestMatchers(HttpMethod.POST, "/api/v1/share-links/*/guests").permitAll()

// 게스트 피드백/답글 참여 — 작성/수정/삭제만 (조회는 인가 검증 이슈에서 처리)
// 게스트 피드백/답글 참여 (조회 포함 — 게스트 소유 검증은 서비스단에서 처리)
.requestMatchers(HttpMethod.POST, "/api/v1/videos/*/feedbacks").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/videos/*/feedbacks").permitAll()
.requestMatchers(HttpMethod.PATCH, "/api/v1/feedbacks/*").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/feedbacks/*").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/feedbacks/*/replies").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/feedbacks/*/replies").permitAll()
.requestMatchers(HttpMethod.PATCH, "/api/v1/replies/*").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/replies/*").permitAll()

Expand Down
Loading