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
@@ -1,13 +1,17 @@
package com.bootsignal.domain.auth.service;

import com.bootsignal.domain.user.entity.User;
import com.bootsignal.global.config.Utf8DotenvLoader;
import com.bootsignal.global.config.properties.PasswordResetProperties;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Primary;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;

/**
Expand All @@ -19,25 +23,39 @@
@ConditionalOnProperty(prefix = "app.auth.password-reset.mail", name = "enabled", havingValue = "true")
public class MailPasswordResetTokenNotifier implements PasswordResetTokenNotifier {

private static final String MAIL_SUBJECT_ENV = "PASSWORD_RESET_MAIL_SUBJECT";

private final JavaMailSender javaMailSender;
private final PasswordResetProperties passwordResetProperties;

@Override
public void send(User user, String rawToken, String resetUrl, Instant expiresAt) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(passwordResetProperties.mail().from());
message.setTo(user.getEmail());
message.setSubject(passwordResetProperties.mail().subject());
message.setText("""
BootSignal 비밀번호 재설정을 요청하셨습니다.
try {
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, StandardCharsets.UTF_8.name());
helper.setFrom(passwordResetProperties.mail().from());
helper.setTo(user.getEmail());
helper.setSubject(resolveSubject());
helper.setText("""
BootSignal 비밀번호 재설정을 요청하셨습니다.

아래 링크에서 새 비밀번호를 설정해 주세요.
%s
아래 링크에서 새 비밀번호를 설정해 주세요.
%s

이 링크는 %s까지 사용할 수 있습니다.
본인이 요청하지 않았다면 이 메일을 무시해 주세요.
""".formatted(resetUrl, expiresAt));
이 링크는 %s까지 사용할 수 있습니다.
본인이 요청하지 않았다면 이 메일을 무시해 주세요.
""".formatted(resetUrl, expiresAt));
javaMailSender.send(mimeMessage);
} catch (MessagingException exception) {
throw new IllegalStateException("비밀번호 재설정 메일 발송에 실패했습니다.", exception);
}
}

javaMailSender.send(message);
private String resolveSubject() {
String fromDotenv = Utf8DotenvLoader.get(MAIL_SUBJECT_ENV);
if (fromDotenv != null && !fromDotenv.isBlank()) {
return fromDotenv;
}
return Utf8DotenvLoader.fixUtf8MisreadAsIso8859(passwordResetProperties.mail().subject());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.bootsignal.domain.user.storage;

import com.bootsignal.global.config.properties.AwsProperties;
import com.bootsignal.global.config.properties.ProfileImageProperties;
import com.bootsignal.global.exception.BootSignalException;
import com.bootsignal.global.exception.ErrorCode;
import java.io.IOException;
import java.util.Set;
import java.util.UUID;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;

public class S3ProfileImageStorage implements ProfileImageStorage {

private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg",
"image/png",
"image/webp"
);

private final S3Client s3Client;
private final AwsProperties awsProperties;
private final ProfileImageProperties profileImageProperties;

public S3ProfileImageStorage(
S3Client s3Client,
AwsProperties awsProperties,
ProfileImageProperties profileImageProperties
) {
if (profileImageProperties.s3() == null
|| !StringUtils.hasText(profileImageProperties.s3().baseUrl())
|| !StringUtils.hasText(profileImageProperties.s3().keyPrefix())) {
throw new IllegalStateException(
"storage-type=s3 설정 시 PROFILE_IMAGE_S3_BASE_URL, PROFILE_IMAGE_S3_KEY_PREFIX 환경 변수가 필요합니다."
);
}
this.s3Client = s3Client;
this.awsProperties = awsProperties;
this.profileImageProperties = profileImageProperties;
}

@Override
public String store(MultipartFile file, Long userId) {
validateFile(file);

String extension = resolveExtension(file);
String key = profileImageProperties.s3().keyPrefix() + "/" + userId + "_" + UUID.randomUUID() + extension;

byte[] bytes;
try {
bytes = file.getBytes();
} catch (IOException exception) {
throw new BootSignalException(ErrorCode.INTERNAL_SERVER_ERROR, "프로필 이미지 저장에 실패했습니다.");
}

try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(awsProperties.s3().bucket())
.key(key)
.contentType(file.getContentType())
.contentLength(file.getSize())
.build(),
RequestBody.fromBytes(bytes)
);
} catch (Exception exception) {
throw new BootSignalException(ErrorCode.INTERNAL_SERVER_ERROR, "프로필 이미지 저장에 실패했습니다.");
}

String baseUrl = profileImageProperties.s3().baseUrl().replaceAll("/$", "");
return baseUrl + "/" + key;
}

private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BootSignalException(ErrorCode.BAD_REQUEST, "프로필 이미지 파일이 필요합니다.");
}
String contentType = file.getContentType();
if (!StringUtils.hasText(contentType) || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new BootSignalException(ErrorCode.BAD_REQUEST, "지원하지 않는 이미지 형식입니다. (jpeg, png, webp)");
}
if (!hasValidImageSignature(file, contentType)) {
throw new BootSignalException(ErrorCode.BAD_REQUEST, "이미지 파일 내용이 형식과 일치하지 않습니다.");
}
}

private String resolveExtension(MultipartFile file) {
return switch (file.getContentType()) {
case "image/png" -> ".png";
case "image/webp" -> ".webp";
default -> ".jpg";
};
}

private boolean hasValidImageSignature(MultipartFile file, String contentType) {
try {
byte[] bytes = file.getBytes();
return switch (contentType) {
case "image/jpeg" -> hasPrefix(bytes, 0xFF, 0xD8, 0xFF);
case "image/png" -> hasPrefix(bytes, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
case "image/webp" -> hasPrefix(bytes, 0x52, 0x49, 0x46, 0x46)
&& hasBytesAt(bytes, 8, 0x57, 0x45, 0x42, 0x50);
default -> false;
};
} catch (IOException exception) {
throw new BootSignalException(ErrorCode.BAD_REQUEST, "이미지 파일을 읽을 수 없습니다.");
}
}

private boolean hasPrefix(byte[] bytes, int... expected) {
return hasBytesAt(bytes, 0, expected);
}

private boolean hasBytesAt(byte[] bytes, int offset, int... expected) {
if (bytes.length < offset + expected.length) {
return false;
}
for (int index = 0; index < expected.length; index++) {
if ((bytes[offset + index] & 0xFF) != expected[index]) {
return false;
}
}
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import com.bootsignal.domain.course_session.entity.CourseSession;
import com.bootsignal.domain.user.entity.User;
import com.bootsignal.global.entity.BaseEntity;
import jakarta.persistence.Basic;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand All @@ -14,7 +13,6 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
Expand Down Expand Up @@ -68,10 +66,8 @@ public class Verification extends BaseEntity {
@Column(name = "job_training_history_file_size")
private Long jobTrainingHistoryFileSize;

@Lob
@Basic(fetch = FetchType.LAZY)
@Column(name = "job_training_history_data", columnDefinition = "LONGBLOB")
private byte[] jobTrainingHistoryData;
@Column(name = "job_training_history_s3_key", length = 500)
private String jobTrainingHistoryS3Key;

@Column(name = "online_course_application_file_name", length = 255)
private String onlineCourseApplicationFileName;
Expand All @@ -82,10 +78,8 @@ public class Verification extends BaseEntity {
@Column(name = "online_course_application_file_size")
private Long onlineCourseApplicationFileSize;

@Lob
@Basic(fetch = FetchType.LAZY)
@Column(name = "online_course_application_data", columnDefinition = "LONGBLOB")
private byte[] onlineCourseApplicationData;
@Column(name = "online_course_application_s3_key", length = 500)
private String onlineCourseApplicationS3Key;

@Column(name = "reject_reason", columnDefinition = "TEXT")
private String rejectReason;
Expand All @@ -108,11 +102,11 @@ private Verification(
String jobTrainingHistoryFileName,
String jobTrainingHistoryContentType,
Long jobTrainingHistoryFileSize,
byte[] jobTrainingHistoryData,
String jobTrainingHistoryS3Key,
String onlineCourseApplicationFileName,
String onlineCourseApplicationContentType,
Long onlineCourseApplicationFileSize,
byte[] onlineCourseApplicationData
String onlineCourseApplicationS3Key
) {
this.user = user;
this.course = course;
Expand All @@ -121,11 +115,11 @@ private Verification(
this.jobTrainingHistoryFileName = jobTrainingHistoryFileName;
this.jobTrainingHistoryContentType = jobTrainingHistoryContentType;
this.jobTrainingHistoryFileSize = jobTrainingHistoryFileSize;
this.jobTrainingHistoryData = jobTrainingHistoryData;
this.jobTrainingHistoryS3Key = jobTrainingHistoryS3Key;
this.onlineCourseApplicationFileName = onlineCourseApplicationFileName;
this.onlineCourseApplicationContentType = onlineCourseApplicationContentType;
this.onlineCourseApplicationFileSize = onlineCourseApplicationFileSize;
this.onlineCourseApplicationData = onlineCourseApplicationData;
this.onlineCourseApplicationS3Key = onlineCourseApplicationS3Key;
}

public void approve(User admin, String adminMemo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.bootsignal.domain.verification.entity.VerificationEvidenceType;
import com.bootsignal.domain.verification.entity.VerificationStatus;
import com.bootsignal.domain.verification.repository.VerificationRepository;
import com.bootsignal.domain.verification.storage.VerificationFileStorage;
import com.bootsignal.global.exception.BootSignalException;
import com.bootsignal.global.exception.ErrorCode;
import com.bootsignal.global.security.SecurityUtil;
Expand All @@ -25,13 +26,16 @@ public class AdminVerificationService {

private final VerificationRepository verificationRepository;
private final UserRepository userRepository;
private final VerificationFileStorage verificationFileStorage;

public AdminVerificationService(
VerificationRepository verificationRepository,
UserRepository userRepository
UserRepository userRepository,
VerificationFileStorage verificationFileStorage
) {
this.verificationRepository = verificationRepository;
this.userRepository = userRepository;
this.verificationFileStorage = verificationFileStorage;
}

public Page<VerificationResponse> getList(VerificationStatus status, Pageable pageable) {
Expand All @@ -48,7 +52,7 @@ public VerificationEvidenceFile getEvidenceFile(
VerificationEvidenceType evidenceType
) {
Verification verification = findVerification(verificationId);
return VerificationEvidenceFileResolver.toEvidenceFile(verification, evidenceType);
return VerificationEvidenceFileResolver.toEvidenceFile(verification, evidenceType, verificationFileStorage);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import com.bootsignal.domain.verification.dto.VerificationEvidenceFile;
import com.bootsignal.domain.verification.entity.Verification;
import com.bootsignal.domain.verification.entity.VerificationEvidenceType;
import com.bootsignal.domain.verification.storage.VerificationFileStorage;
import com.bootsignal.global.exception.BootSignalException;
import com.bootsignal.global.exception.ErrorCode;
import org.springframework.util.StringUtils;

/**
* 인증 신청 엔티티에 저장된 자료 유형별 BLOB 데이터를 다운로드 DTO로 변환하는 클래스입니다.
* 인증 신청 엔티티에 저장된 S3 키를 이용해 파일을 다운로드하고 DTO로 변환하는 클래스입니다.
*/
final class VerificationEvidenceFileResolver {

Expand All @@ -17,23 +18,33 @@ private VerificationEvidenceFileResolver() {

static VerificationEvidenceFile toEvidenceFile(
Verification verification,
VerificationEvidenceType evidenceType
VerificationEvidenceType evidenceType,
VerificationFileStorage fileStorage
) {
byte[] evidenceData = resolveData(verification, evidenceType);
if (evidenceData == null || evidenceData.length == 0) {
String s3Key = resolveS3Key(verification, evidenceType);
if (!StringUtils.hasText(s3Key)) {
throw new BootSignalException(
ErrorCode.VERIFICATION_EVIDENCE_INVALID,
evidenceType.displayName() + " 정보가 올바르지 않습니다."
);
}

byte[] data = fileStorage.download(s3Key);

return new VerificationEvidenceFile(
resolveFileName(verification, evidenceType),
resolveContentType(verification, evidenceType),
evidenceData
data
);
}

private static String resolveS3Key(Verification verification, VerificationEvidenceType evidenceType) {
return switch (evidenceType) {
case JOB_TRAINING_HISTORY -> verification.getJobTrainingHistoryS3Key();
case ONLINE_COURSE_APPLICATION -> verification.getOnlineCourseApplicationS3Key();
};
}

private static String resolveFileName(Verification verification, VerificationEvidenceType evidenceType) {
String fileName = switch (evidenceType) {
case JOB_TRAINING_HISTORY -> verification.getJobTrainingHistoryFileName();
Expand All @@ -48,11 +59,4 @@ private static String resolveContentType(Verification verification, Verification
case ONLINE_COURSE_APPLICATION -> verification.getOnlineCourseApplicationContentType();
};
}

private static byte[] resolveData(Verification verification, VerificationEvidenceType evidenceType) {
return switch (evidenceType) {
case JOB_TRAINING_HISTORY -> verification.getJobTrainingHistoryData();
case ONLINE_COURSE_APPLICATION -> verification.getOnlineCourseApplicationData();
};
}
}
Loading
Loading