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 @@ -67,6 +67,7 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) {
)
public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) {
validateKoreanNameNotExists(request.koreanName());
validateEnglishNameNotExists(request.englishName());

Country country = findCountryByCode(request.countryCode());
Region region = findRegionByCode(request.regionCode());
Expand Down Expand Up @@ -97,6 +98,13 @@ private void validateKoreanNameNotExists(String koreanName) {
});
}

private void validateEnglishNameNotExists(String englishName) {
hostUniversityRepository.findByEnglishName(englishName)
.ifPresent(existingUniversity -> {
throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS);
});
}

@Transactional
@DefaultCacheOut(
key = {"univApplyInfoTextSearch", "university:recommend:general"},
Expand All @@ -108,6 +116,7 @@ public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHost
.orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND));

validateKoreanNameNotDuplicated(request.koreanName(), id);
validateEnglishNameNotDuplicated(request.englishName(), id);

Country country = findCountryByCode(request.countryCode());
Region region = findRegionByCode(request.regionCode());
Expand Down Expand Up @@ -140,6 +149,15 @@ private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId)
});
}

private void validateEnglishNameNotDuplicated(String englishName, Long excludeId) {
hostUniversityRepository.findByEnglishName(englishName)
.ifPresent(existingUniversity -> {
if (!existingUniversity.getId().equals(excludeId)) {
throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS);
}
});
}

private Country findCountryByCode(String countryCode) {
return countryRepository.findByCode(countryCode)
.orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.example.solidconnection.s3.controller;

import com.example.solidconnection.common.resolver.AuthorizedUser;
import com.example.solidconnection.s3.domain.UploadDirectoryName;
import com.example.solidconnection.s3.domain.UploadPath;
import com.example.solidconnection.s3.dto.UploadedFileUrlResponse;
import com.example.solidconnection.s3.dto.UrlPrefixResponse;
import com.example.solidconnection.s3.service.S3Service;
import com.example.solidconnection.security.annotation.RequireRoleAccess;
import com.example.solidconnection.siteuser.domain.Role;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand Down Expand Up @@ -77,6 +80,38 @@ public ResponseEntity<List<UploadedFileUrlResponse>> uploadChatFile(
return ResponseEntity.ok(chatImageUrls);
}

@RequireRoleAccess(roles = Role.ADMIN)
@PostMapping("/admin/university/logo")
public ResponseEntity<UploadedFileUrlResponse> uploadAdminUniversityLogo(
@AuthorizedUser long adminId,
@RequestParam("file") MultipartFile imageFile,
@RequestParam("englishName") String englishName
) {
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
UploadedFileUrlResponse logoImageUrl = s3Service.uploadFile(
imageFile,
UploadPath.ADMIN_UNIVERSITY_LOGO,
directoryName
);
return ResponseEntity.ok(logoImageUrl);
}

@RequireRoleAccess(roles = Role.ADMIN)
@PostMapping("/admin/university/background")
public ResponseEntity<UploadedFileUrlResponse> uploadAdminUniversityBackground(
@AuthorizedUser long adminId,
@RequestParam("file") MultipartFile imageFile,
Comment thread
Hexeong marked this conversation as resolved.
@RequestParam("englishName") String englishName
) {
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
UploadedFileUrlResponse backgroundImageUrl = s3Service.uploadFile(
imageFile,
UploadPath.ADMIN_UNIVERSITY_BACKGROUND,
directoryName
);
return ResponseEntity.ok(backgroundImageUrl);
}

@GetMapping("/s3-url-prefix")
public ResponseEntity<UrlPrefixResponse> getS3UrlPrefix() {
return ResponseEntity.ok(new UrlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.example.solidconnection.s3.domain;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.exception.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

public final class UploadDirectoryName {

private static final int HASH_PREFIX_LENGTH = 12;

private UploadDirectoryName() {
}

public static String fromUniversityEnglishName(String englishName) {
if (englishName == null || englishName.isBlank()) {
throw new CustomException(ErrorCode.INVALID_INPUT);
}

String directoryName = englishName.trim()
.toLowerCase()
.replaceAll("\\s*&\\s*", "_and_")
.replaceAll("\\s+", "_")
.replaceAll("_+", "_")
.replaceAll("[^a-z0-9_-]", "");

if (directoryName.isBlank()) {
throw new CustomException(ErrorCode.INVALID_INPUT);
}

return directoryName + "_" + hash(englishName.trim());
}

private static String hash(String value) {
try {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
byte[] digest = messageDigest.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest).substring(0, HASH_PREFIX_LENGTH);
} catch (NoSuchAlgorithmException e) {
throw new CustomException(ErrorCode.NOT_DEFINED_ERROR);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,28 @@
import com.example.solidconnection.common.constant.FileConstants;
import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.exception.ErrorCode;
import java.util.List;
import lombok.Getter;

@Getter
public enum UploadPath {
PROFILE("profile"),
GPA("gpa"),
LANGUAGE_TEST("language"),
COMMUNITY("community"),
NEWS("news"),
CHAT("chat/files"),
MENTOR_PROOF("mentor-proof"),
PROFILE("profile", true),
GPA("gpa", false),
LANGUAGE_TEST("language", false),
COMMUNITY("community", true),
NEWS("news", true),
CHAT("chat/files", false),
MENTOR_PROOF("mentor-proof", false),
ADMIN_UNIVERSITY_LOGO("admin/logo", true),
ADMIN_UNIVERSITY_BACKGROUND("admin/background", true)
;

private final String type;
private final boolean imageOnly;

UploadPath(String type) {
UploadPath(String type, boolean imageOnly) {
this.type = type;
this.imageOnly = imageOnly;
}

public boolean isResizable(long fileSize, String extension, long maxSizeBytes) {
Expand All @@ -35,7 +40,7 @@ public boolean isResizable(long fileSize, String extension, long maxSizeBytes) {
}

public void validateExtension(String extension) {
if (extension == null || !FileConstants.ALL_ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
if (extension == null || !getAllowedExtensions().contains(extension.toLowerCase())) {
throw new CustomException(ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS,
"허용된 형식: " + getAllowedExtensionsMessage());
}
Comment on lines 42 to 46

@coderabbitai coderabbitai Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

1) 확장자-only 검증은 우회 업로드를 막지 못합니다.

Line 43 기준으로 현재 검증은 파일명 확장자만 신뢰합니다. 이 방식은 내용이 다른 파일(예: 실행파일/스크립트)을 확장자만 바꿔 통과시킬 수 있어요. 최소한 매직바이트(파일 시그니처) 기반 검증을 추가하고, 확장자 검증은 보조 수단으로 두는 편이 안전합니다.

🔧 제안 패치 (개념 예시)
 public void validateExtension(String extension) {
     if (extension == null || !getAllowedExtensions().contains(extension.toLowerCase())) {
         throw new CustomException(ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS,
                                   "허용된 형식: " + getAllowedExtensionsMessage());
     }
 }
+
+// S3Service.validateFile(...)에서 확장자 검증 후 추가:
+// - imageOnly 경로: JPEG/PNG/WEBP 등 허용 포맷 시그니처 검사
+// - 비 imageOnly 경로: MIME + 시그니처 기반 최소 검증
🤖 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/example/solidconnection/s3/domain/UploadPath.java` around
lines 42 - 46, The validateExtension method in the UploadPath class currently
only checks file extensions, which can be bypassed by renaming malicious files.
Enhance the validation by implementing magic byte (file signature) based
validation as the primary check and use the existing extension validation as a
secondary verification method. Add a new validation method to verify the actual
file content against expected magic bytes for each allowed file type, and
integrate this content-based check into the validateExtension method so that
both the file content signature and extension must match the allowed types
before the file is accepted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

이건 나주에 수정할게

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@lsy1307 알겠어요! 나중에 작업하실 때 놓치지 않도록, 팔로업 이슈를 생성해 드릴까요?

  1. 이슈 내용 요약
    • UploadPath.validateExtension에 매직바이트(파일 시그니처) 기반 검증 추가
    • 확장자 검증은 보조 수단으로 유지
    • imageOnly 경로와 그 외 경로에 대한 검증 전략 분리

원하시면 "이슈 만들어줘"라고 말씀해 주세요 😊

(๑˃ᵕ˂)و ✨

Expand All @@ -46,6 +51,13 @@ public boolean isImage(String extension) {
}

public String getAllowedExtensionsMessage() {
return String.join(", ", FileConstants.ALL_ALLOWED_EXTENSIONS);
return String.join(", ", getAllowedExtensions());
}

private List<String> getAllowedExtensions() {
if (imageOnly) {
return FileConstants.IMAGE_EXTENSIONS;
}
return FileConstants.ALL_ALLOWED_EXTENSIONS;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,20 @@ public class S3Service {
* - 5mb 미만의 파일은 바로 업로드한다.
* */
public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, UploadPath uploadPath) {
return uploadFile(multipartFile, uploadPath, null);
}

public UploadedFileUrlResponse uploadFile(
MultipartFile multipartFile,
UploadPath uploadPath,
String subDirectory
) {
validateFile(multipartFile, uploadPath);

UUID randomUUID = UUID.randomUUID();
String extension = getFileExtension(Objects.requireNonNull(multipartFile.getOriginalFilename()));
String baseFileName = randomUUID + "." + extension;
String fileName = uploadPath.getType() + "/" + baseFileName;
String fileName = createFileName(uploadPath, subDirectory, baseFileName);

final boolean shouldResize = uploadPath.isResizable(
multipartFile.getSize(), extension, MAX_FILE_SIZE_MB);
Expand All @@ -73,6 +81,13 @@ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, UploadPat
return new UploadedFileUrlResponse(returnPath);
}

private String createFileName(UploadPath uploadPath, String subDirectory, String baseFileName) {
if (subDirectory == null || subDirectory.isBlank()) {
return uploadPath.getType() + "/" + baseFileName;
}
return uploadPath.getType() + "/" + subDirectory + "/" + baseFileName;
}

private byte[] extractBytes(MultipartFile file) {
try {
return file.getBytes();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class HostUniversity extends BaseEntity {
@Column(name = "korean_name", nullable = false, unique = true, length = 100)
private String koreanName;

@Column(name = "english_name", nullable = false, length = 100)
@Column(name = "english_name", nullable = false, unique = true, length = 100)
private String englishName;

@Column(name = "format_name", nullable = false, length = 100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ default HostUniversity getHostUniversityById(Long id) {
}

Optional<HostUniversity> findByKoreanName(String koreanName);

Optional<HostUniversity> findByEnglishName(String englishName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE host_university
ADD CONSTRAINT uk_host_university_english_name UNIQUE (english_name);
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,31 @@ class 생성 {
.isInstanceOf(CustomException.class)
.hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage());
}

@Test
void 이미_존재하는_영문명으로_생성하면_예외_응답을_반환한다() {
// given
HostUniversity existing = universityFixture.괌_대학();
Country country = countryFixture.미국();
Region region = regionFixture.영미권();

AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest(
"신규 대학",
existing.getEnglishName(),
"표시명",
null, null, null,
"https://logo.com/image.png",
"https://background.com/image.png",
null,
country.getCode(),
region.getCode()
);

// when & then
assertThatCode(() -> adminHostUniversityService.createHostUniversity(request))
.isInstanceOf(CustomException.class)
.hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage());
}
}

@Nested
Expand Down Expand Up @@ -341,6 +366,30 @@ class 수정 {
.hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage());
}

@Test
void 다른_대학의_영문명으로_수정하면_예외_응답을_반환한다() {
// given
HostUniversity university1 = universityFixture.괌_대학();
HostUniversity university2 = universityFixture.메이지_대학();

AdminHostUniversityUpdateRequest request = new AdminHostUniversityUpdateRequest(
university1.getKoreanName(),
university2.getEnglishName(),
"수정된 표시명",
null, null, null,
"https://logo.com/image.png",
"https://background.com/image.png",
null,
university1.getCountry().getCode(),
university1.getRegion().getCode()
);

// when & then
assertThatCode(() -> adminHostUniversityService.updateHostUniversity(university1.getId(), request))
.isInstanceOf(CustomException.class)
.hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage());
}

@Test
void 같은_대학의_한글명으로_수정하면_성공한다() {
// given
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.example.solidconnection.s3.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.example.solidconnection.common.exception.CustomException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@DisplayName("업로드 디렉토리명 테스트")
class UploadDirectoryNameTest {

@Nested
class 대학_영문명_변환_테스트 {

@Test
void 대학_영문명의_공백을_언더스코어로_변환한다() {
// given
String englishName = "University of Tokyo";

// when
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);

// then
assertThat(directoryName)
.startsWith("university_of_tokyo_")
.matches("university_of_tokyo_[0-9a-f]{12}");
}

@Test
void 특수문자를_제거하고_앰퍼샌드는_and로_변환한다() {
// given
String englishName = "Texas A&M University, Austin";

// when
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);

// then
assertThat(directoryName)
.startsWith("texas_a_and_m_university_austin_")
.matches("texas_a_and_m_university_austin_[0-9a-f]{12}");
}

@Test
void 같은_slug로_변환되는_서로_다른_영문명은_다른_디렉토리명을_반환한다() {
// given
String englishName = "Texas A&M University";
String normalizedCollisionName = "Texas A and M University";

// when
String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName);
String collisionDirectoryName = UploadDirectoryName.fromUniversityEnglishName(normalizedCollisionName);

// then
assertThat(directoryName).isNotEqualTo(collisionDirectoryName);
}

@Test
void 공백_문자열이면_예외가_발생한다() {
// given
String blankName = " ";

// when & then
assertThatThrownBy(() -> UploadDirectoryName.fromUniversityEnglishName(blankName))
.isInstanceOf(CustomException.class);
}
}
}
Loading
Loading