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 @@ -35,10 +35,14 @@
/**
* 활동사진 조회·업로드·삭제 (#57, spec 3-2 §3-2-5).
*
* <p><b>조회는 {@code isAuthenticated()}, 쓰기는 {@code hasRole('ADMIN')}만 적는다.</b> {@code ACTIVE} 조건은
* {@code AccountStatusFilter}가 인가보다 먼저 보장하므로 여기서 다시 적지 않는다 ({@code NoticeController}와 같은 관례).
* <p><b>전부 {@code isAuthenticated()}만 적는다.</b> 상태 조건은 {@code AccountStatusFilter}가 인가보다 먼저 보장하므로
* 여기서 다시 적지 않는다 ({@code NoticeController}와 같은 관례).
*
* <p><b>업로드가 부원 전체에게 열려 있다</b> (2026-09-03, #400). 소모임장이 사진을 올릴 수 있게 하려고 연 것이고, 그 하나 때문에 관리자 권한을 주는
* 것은 너무 크다. 삭제의 <b>"본인 것만"</b>은 역할이 아니라 소유자 판단이라 {@code @PreAuthorize}로 적을 수 없다 — {@code
* PhotoService}가 행을 잠근 채 본다 (3-1 §3-1-7: 화면이 버튼을 숨기는 것과 별개로 서버가 확인한다).
*/
@Tag(name = "활동사진", description = "조회는 ACTIVE, 업로드·삭제는 ADMIN 전용. 원본은 presigned URL로 직접 올린다")
@Tag(name = "활동사진", description = "조회·업로드는 부원 전체. 삭제는 본인 것만(ADMIN은 전체). 원본은 presigned URL로 직접 올린다")
@RestController
@RequestMapping("/api/v1/photos")
public class PhotoController {
Expand Down Expand Up @@ -75,7 +79,7 @@ public PhotoController(PhotoService photoService, PhotoLikeService photoLikeServ
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)))
@PostMapping("/upload-url")
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("isAuthenticated()")
public List<PhotoUploadUrlResponse> issueUploadUrls(
@Valid @RequestBody PhotoUploadUrlRequest request) {
return photoService.issueUploadUrls(request.extensions());
Expand Down Expand Up @@ -104,7 +108,7 @@ public List<PhotoUploadUrlResponse> issueUploadUrls(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)))
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("isAuthenticated()")
public PhotoRegisterResponse register(
@AuthenticationPrincipal Long userId, @Valid @RequestBody PhotoRegisterRequest request) {
return photoService.register(userId, request);
Expand All @@ -121,7 +125,22 @@ public PagedModel<PhotoResponse> list(
return new PagedModel<>(photoService.list(pageable, viewerId, liked));
}

@Operation(summary = "활동사진 삭제")
@Operation(
summary = "활동사진 삭제",
description =
"""
**본인이 올린 사진만 지운다.** `ADMIN`은 전체를 지운다 (#400).

**업로더가 비어 있는 사진**(탈퇴한 회원의 것)은 `ADMIN`만 지운다 — 주인이 없어
"본인"이 성립하지 않는다. 자료와 같은 규칙이다.
""")
@ApiResponse(
responseCode = "403",
description = "`FORBIDDEN` — 남이 올린 사진",
content =
@Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "204", description = "삭제됨")
@ApiResponse(
responseCode = "404",
Expand All @@ -131,7 +150,7 @@ public PagedModel<PhotoResponse> list(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)))
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("isAuthenticated()")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@AuthenticationPrincipal Long userId, @PathVariable Long id) {
photoService.delete(userId, id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
import org.hackerkhu.hackerhp.domain.photo.repository.PhotoRepository;
import org.hackerkhu.hackerhp.domain.user.dto.DisplayName;
import org.hackerkhu.hackerhp.domain.user.entity.Role;
import org.hackerkhu.hackerhp.domain.user.entity.Status;
import org.hackerkhu.hackerhp.domain.user.entity.User;
import org.hackerkhu.hackerhp.domain.user.repository.UserRepository;
import org.hackerkhu.hackerhp.domain.user.service.RequesterCheck;
import org.hackerkhu.hackerhp.global.error.BusinessException;
import org.hackerkhu.hackerhp.global.error.ErrorCode;
import org.hackerkhu.hackerhp.global.storage.FileStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -49,6 +51,8 @@
@Service
public class PhotoService {

private static final Logger log = LoggerFactory.getLogger(PhotoService.class);

private static final Set<String> ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png");

/** 원본 크기 상한. 계약이 정한 값이 아니라 방어적 상한이다 — 자료(§2-1-2)의 파일당 20MB와 같은 자릿수로 맞췄다. */
Expand Down Expand Up @@ -96,7 +100,7 @@ private PhotoUploadUrlResponse issueUploadUrl(String rawExtension) {
* (apps/api/AGENTS.md, spec 3-2 §3-2-6 일괄 승인과 같은 원칙). 항목마다 독립된 트랜잭션으로 처리하므로 뒤 항목의 실패가 이미 커밋된 앞
* 항목을 되돌리지도 않는다.
*
* <p>요청자 권한이 통째로 사라진 경우({@link #requireActiveAdmin})는 예외다 — 그건 개별 항목의 문제가 아니라 요청 자체가 더 이상 유효하지
* <p>요청자가 더 이상 쓸 수 없는 계정인 경우({@link #requireWritable})는 예외다 — 그건 개별 항목의 문제가 아니라 요청 자체가 더 이상 유효하지
* 않다는 뜻이므로 {@link BusinessException}을 그대로 던져 나머지 항목의 처리를 멈춘다.
*/
public PhotoRegisterResponse register(Long uploaderId, PhotoRegisterRequest request) {
Expand All @@ -117,9 +121,9 @@ public PhotoRegisterResponse register(Long uploaderId, PhotoRegisterRequest requ
* 사진 한 장을 등록한다. 되돌릴 수 없는 순서로 세 단계를 밟는다.
*
* <ol>
* <li>요청자 권한을 잠근 채 다시 확인하고({@link #requireActiveAdmin}), 자리표시자 경로(임시 키)로 행을 만들어 커밋한다 — 최종
* 키({@code photos/{photoId}/{uuid}.jpg})에 이 행의 id가 들어가야 하는데, INSERT 전에는 id가 없다. 이 행은 아직
* "완결"되지 않았으므로 {@link PhotoRepository#findByStoredPathNotStartingWith}가 목록에서 뺀다.
* <li>요청자 상태를 잠근 채 다시 확인하고({@link #requireWritable}), 자리표시자 경로(임시 키)로 행을 만들어 커밋한다 — 최종 키({@code
* photos/{photoId}/{uuid}.jpg})에 이 행의 id가 들어가야 하는데, INSERT 전에는 id가 없다. 이 행은 아직 "완결"되지 않았으므로
* {@link PhotoRepository#findByStoredPathNotStartingWith}가 목록에서 뺀다.
* <li>그 id로 최종 키를 만들어 리사이즈본·썸네일을 올린 뒤, 요청자 권한을 <b>다시 한번</b> 확인하고 행에 최종 키를 반영해 커밋한다 — 1번 트랜잭션이
* 끝나며 잠금은 이미 풀렸고 S3 업로드는 트랜잭션 밖이라, 그 사이 다른 관리자가 요청자를 정지·강등했을 수 있다. <b>이 중 무엇이든 실패하면 1번이 만든
* 행을 지운다</b> — 임시 원본은 아직 그대로라 같은 키로 다시 등록을 시도할 수 있다. S3 업로드 실패는 항목 하나의 문제가 아니라 S3 자체의 문제이므로
Expand Down Expand Up @@ -175,7 +179,7 @@ private PhotoResponse registerOne(Long uploaderId, PhotoRegisterRequest.Item ite
Photo photo =
transactionTemplate.execute(
status -> {
requireActiveAdmin(uploaderId);
requireWritable(uploaderId);
Photo created =
Photo.upload(caption, tempKey, userRepository.getReferenceById(uploaderId));
photoRepository.saveAndFlush(created);
Expand All @@ -198,7 +202,7 @@ private PhotoResponse registerOne(Long uploaderId, PhotoRegisterRequest.Item ite
response =
transactionTemplate.execute(
status -> {
requireActiveAdmin(uploaderId);
requireWritable(uploaderId);
Photo managed = photoRepository.getReferenceById(photoId);
managed.assignStoredPath(finalKey);
// 방금 등록한 사진이라 좋아요는 아직 없다.
Expand Down Expand Up @@ -287,26 +291,50 @@ public void delete(Long requesterId, Long id) {
}

private String deleteRow(Long requesterId, Long id) {
requireActiveAdmin(requesterId);
User requester = requireWritable(requesterId);
Photo photo =
photoRepository.findById(id).orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));
requireOwnerOrAdmin(requester, photo, requesterId);
String storedPath = photo.getStoredPath();
photoRepository.delete(photo);
return storedPath;
}

/**
* 요청자의 <b>현재</b> 권한을 행을 잠근 채 확인한다. {@code @PreAuthorize}는 <b>세션에 담긴</b> 값을 본다 — 등록·삭제가 S3와 왕복하는
* 동안 다른 관리자가 이 계정을 강등·정지했다면, 세션은 여전히 ADMIN이라 판단하지만 실제 권한은 이미 사라진 뒤다. 되돌릴 수 없는 S3 쓰기를 시작하기 전에 반드시
* 부른다 ({@link UserApplicationService#submit}과 같은 이유로 {@code findByIdForUpdate}를 쓴다).
* 요청자의 <b>현재</b> 상태를 행을 잠근 채 확인한다. {@code @PreAuthorize}는 <b>세션에 담긴</b> 값을 본다 — 등록·삭제가 S3와 왕복하는
* 동안 다른 관리자가 이 계정을 정지했다면, 세션은 여전히 쓸 수 있다고 판단하지만 실제로는 아니다. 되돌릴 수 없는 S3 쓰기를 시작하기 전에 반드시 부른다 ({@link
* UserApplicationService#submit}과 같은 이유로 {@code findByIdForUpdate}를 쓴다).
*
* <p><b>{@code ADMIN}을 요구하지 않는다</b> (2026-09-03, #400). 활동사진 업로드가 부원 전체에게 열리면서 조건이 "활성 관리자인가"에서
* "쓸 수 있는 계정인가"로 바뀌었다. {@code RequesterCheck#requireActive}가 그 판단을 이미 들고 있다 — {@code INACTIVE}는
* 통과하고 {@code SUSPENDED}·{@code PENDING}은 각자의 코드로 거절된다.
*
* <p><b>검사 자체를 지우면 안 된다.</b> 이 메서드가 있는 이유는 권한 등급이 아니라 <b>세션과 실제 상태가 어긋나는 창</b>이다. 그 창은 조건이 느슨해져도
* 그대로 있다.
*/
private void requireActiveAdmin(Long requesterId) {
User requester =
userRepository
.findByIdForUpdate(requesterId)
.orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHENTICATED));
if (requester.getRole() != Role.ADMIN || requester.getStatus() != Status.ACTIVE) {
throw new BusinessException(ErrorCode.FORBIDDEN);
private User requireWritable(Long requesterId) {
User requester = userRepository.findByIdForUpdate(requesterId).orElse(null);
RequesterCheck.requireActive(requester, requesterId);
return requester;
}

/**
* 지울 수 있는 사람인가 — <b>{@code ADMIN}은 전부, 그 밖에는 본인이 올린 것만</b> (3-1 §3-1-3, #400).
*
* <p>자료와 같은 규칙이다 ({@code NoteEditService#requireOwnerOrAdmin}). <b>업로더가 비어 있는 사진</b>(탈퇴한 회원의 것)은
* {@code ADMIN}만 지운다 — 주인이 없으므로 "본인"이 성립하지 않는다.
*
* <p><b>이름이 아니라 id로 견준다.</b> 표시 이름은 동명이인을 가르려고 학번 뒷자리를 붙인 값이라(#300) 비교 기준이 될 수 없고, 탈퇴한 회원끼리는 이름이
* 모두 같다.
*/
private void requireOwnerOrAdmin(User requester, Photo photo, Long requesterId) {
if (requester.getRole() == Role.ADMIN) {
return;
}
User uploader = photo.getUploader();
if (uploader == null || !requesterId.equals(uploader.getId())) {
log.info("남의 사진을 지우려 했다: requesterId={} photoId={}", requesterId, photo.getId());
throw new BusinessException(ErrorCode.FORBIDDEN, "본인이 올린 사진만 삭제할 수 있습니다.");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.requestMatchers("/api/v1/admin/**")
.hasRole("ADMIN")
/*
* 활동사진 쓰기(#57)도 같은 이유로 필터에서 한 번 더 막는다 — PhotoController의
* @PreAuthorize는 본문을 역직렬화한 뒤에야 걸리므로, ACTIVE 회원이 깨진 본문을
* 보내면 @PreAuthorize에 닿기도 전에 400이 나가 "권한 없으면 403" 계약이 깨진다.
* 활동사진 쓰기에는 역할 조건이 없다 (2026-09-03, #400). 업로드는 부원 전체에게
* 열렸고, 삭제의 "본인 것만"은 역할이 아니라 소유자 판단이라 경로만 보고는 정할 수
* 없다 — PhotoService가 행을 잠근 채 본다.
*
* **`**`가 아니라 정확한 경로다** (#346 리뷰). 좋아요(`POST`·`DELETE
* /photos/{id}/like`)는 ACTIVE·INACTIVE에게 열려야 하는데, 와일드카드로 두면
* 그 경로까지 ADMIN 전용으로 막혀 컨트롤러의 @PreAuthorize("isAuthenticated()")에
* 닿기도 전에 여기서 거절된다.
* 예전에는 여기서 ADMIN을 강제했다. 그때 적어 둔 두 가지는 지금도 유효하다.
*
* ① 필터가 먼저 거절하면 본문을 읽기 전이라 "권한 없으면 403"이 본문 모양에
* 흔들리지 않는다. 지금은 거절할 역할 조건 자체가 없어 해당하지 않는다.
* ② 경로를 `**`로 뭉치지 않는다 (#346 리뷰). 좋아요(`/photos/{id}/like`)까지
* 함께 걸려 컨트롤러의 @PreAuthorize에 닿기도 전에 막힌다.
*
* ②는 새 규칙을 여기 더할 때 다시 걸린다. 그래서 매처를 지우면서 이 경고를 남긴다.
*/
.requestMatchers(HttpMethod.POST, "/api/v1/photos", "/api/v1/photos/upload-url")
.hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/v1/photos/*")
.hasRole("ADMIN")
.anyRequest()
.authenticated();
})
Expand Down
Loading
Loading