Skip to content
Open
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 @@ -6,4 +6,10 @@ public interface ImageUploader {
* 실패 시 null 값을 반환한다 (Soft Fail).
*/
String uploadFromUrl(String originalUrl);

/**
* 이미지 URL이 이미 스토리지에 저장되어 있다면 해당 URL을 반환한다.
* 저장된 URL이 아니거나 확인할 수 없으면 null을 반환한다.
*/
String resolveStoredUrl(String originalUrl);
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ public class LinkFacade {
private final ImageUploader imageUploader;

public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) {
String storedImageUrl = imageUploader.uploadFromUrl(imageUrl);
String storedImageUrl = imageUploader.resolveStoredUrl(imageUrl);
if (storedImageUrl == null) {
storedImageUrl = imageUploader.uploadFromUrl(imageUrl);
}
Link link = linkService.createLink(member, url, title, memo, storedImageUrl);
return LinkRes.from(link);
}
Expand Down Expand Up @@ -96,6 +99,17 @@ public RecreateSummaryResponse recreateSummary(Member member, Long linkId, Forma
@Transactional(readOnly = true)
public MetaScrapeRes scrapeMetadata(String url) {
OgTagDto ogTag = ogTagCrawler.crawl(url);
return MetaScrapeRes.from(ogTag);
String imageUrl = ogTag.image();
String uploadedImageUrl = imageUrl == null || imageUrl.isBlank()
? null
: imageUploader.uploadFromUrl(imageUrl);

String responseImageUrl = uploadedImageUrl != null ? uploadedImageUrl : imageUrl;
return new MetaScrapeRes(
ogTag.title(),
ogTag.description(),
responseImageUrl,
ogTag.url()
);
}
}
68 changes: 61 additions & 7 deletions src/main/java/com/sofa/linkiving/infra/s3/S3ImageUploader.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.stereotype.Component;

import com.sofa.linkiving.domain.link.abstraction.ImageUploader;
import com.sofa.linkiving.domain.link.util.UrlValidator;
import com.sofa.linkiving.global.error.exception.BusinessException;

import io.awspring.cloud.s3.S3Template;
import lombok.RequiredArgsConstructor;
Expand All @@ -21,28 +23,33 @@ public class S3ImageUploader implements ImageUploader {

private final S3Template s3Template;
private final UrlConnectionFactory urlConnectionFactory;
private final UrlValidator urlValidator;

@Value("${spring.cloud.aws.s3.bucket}")
private String bucketName;

@Value("${spring.cloud.aws.region.static}")
private String region;

@Value("${app.link.default-image-url:}")
private String defaultImageUrl;

@Override
public String uploadFromUrl(String originalUrl) {
if (originalUrl == null || originalUrl.isBlank()) {
return null;
}

try {
String s3Key = generateUniqueKeyFromUrl(originalUrl);
String cachedUrl = resolveStoredUrl(originalUrl);
if (cachedUrl != null) {
return cachedUrl;
}

String s3Url = buildS3Url(s3Key);
urlValidator.validateSafeUrl(originalUrl);

if (s3Template.objectExists(bucketName, s3Key)) {
log.info("Image already exists (Cache Hit): {} -> {}", originalUrl, s3Key);
return s3Url;
}
String s3Key = generateUniqueKeyFromUrl(originalUrl);
String s3Url = buildS3Url(s3Key);

URLConnection connection = urlConnectionFactory.createConnection(originalUrl);
connection.setConnectTimeout(3000);
Expand All @@ -60,8 +67,39 @@ public String uploadFromUrl(String originalUrl) {
return s3Url;
}

} catch (BusinessException e) {
log.warn("Unsafe image URL blocked: {}", e.getMessage());
return normalizedDefaultImageUrl();
} catch (Exception e) {
log.warn("Upload failed, falling back to original URL: {}", e.getMessage());
log.warn("Image upload failed: {}", e.getMessage());
return null;
}
}

@Override
public String resolveStoredUrl(String originalUrl) {
if (originalUrl == null || originalUrl.isBlank()) {
return null;
}

try {
String storedKey = extractStoredKey(originalUrl);
if (storedKey != null) {
if (s3Template.objectExists(bucketName, storedKey)) {
return originalUrl;
}
return null;
}

String s3Key = generateUniqueKeyFromUrl(originalUrl);
if (s3Template.objectExists(bucketName, s3Key)) {
log.info("Image already exists (Cache Hit): {} -> {}", originalUrl, s3Key);
return buildS3Url(s3Key);
}

return null;
} catch (Exception e) {
log.warn("Image cache lookup failed: {}", e.getMessage());
return null;
}
}
Expand All @@ -70,6 +108,22 @@ private String buildS3Url(String key) {
return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key);
}

private String extractStoredKey(String url) {
String prefix = String.format("https://%s.s3.%s.amazonaws.com/", bucketName, region);
if (!url.startsWith(prefix)) {
return null;
}
String key = url.substring(prefix.length());
return key.isBlank() ? null : key;
}

private String normalizedDefaultImageUrl() {
if (defaultImageUrl == null || defaultImageUrl.isBlank()) {
return null;
}
return defaultImageUrl;
}

private String generateUniqueKeyFromUrl(String url) {
try {
String extension = "jpg";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,17 @@ void setUp() {
void shouldReturnMetaScrapeResWhenCrawlSucceeds() {
// given
String url = "https://velog.io/@jjeongdong/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4";
String originalImageUrl = "https://velog.io/images/thumbnail.png";
String storedImageUrl = "https://s3-bucket.com/links/uuid.png";
OgTagDto mockOgTag = OgTagDto.builder()
.title("동시성 제어")
.description("동시성 제어에 대한 설명")
.image("https://velog.io/images/thumbnail.png")
.image(originalImageUrl)
.url(url)
.build();

given(ogTagCrawler.crawl(url)).willReturn(mockOgTag);
given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl);

// when
MetaScrapeRes result = linkFacade.scrapeMetadata(url);
Expand All @@ -88,9 +91,10 @@ void shouldReturnMetaScrapeResWhenCrawlSucceeds() {
assertThat(result).isNotNull();
assertThat(result.title()).isEqualTo("동시성 제어");
assertThat(result.description()).isEqualTo("동시성 제어에 대한 설명");
assertThat(result.image()).isEqualTo("https://velog.io/images/thumbnail.png");
assertThat(result.image()).isEqualTo(storedImageUrl);
assertThat(result.url()).isEqualTo(url);
verify(ogTagCrawler, times(1)).crawl(url);
verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl);
}

@Test
Expand Down Expand Up @@ -151,6 +155,8 @@ void shouldReturnEmptyMetaScrapeResWhenCrawlFails() {
assertThat(result.image()).isEmpty();
assertThat(result.url()).isEmpty();
verify(ogTagCrawler, times(1)).crawl(url);
verify(imageUploader, never()).uploadFromUrl(any());
verify(imageUploader, never()).resolveStoredUrl(any());
}

@Test
Expand All @@ -164,6 +170,7 @@ void shouldCreateLink() {
String originalImageUrl = "https://original.com/image.jpg";
String storedImageUrl = "https://s3-bucket.com/stored-image.jpg";

given(imageUploader.resolveStoredUrl(originalImageUrl)).willReturn(null);
given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(storedImageUrl);

Link savedLink = Link.builder()
Expand All @@ -185,6 +192,7 @@ void shouldCreateLink() {
assertThat(result.imageUrl()).isEqualTo(storedImageUrl);

// Verify
verify(imageUploader, times(1)).resolveStoredUrl(originalImageUrl);
verify(imageUploader, times(1)).uploadFromUrl(originalImageUrl);
verify(linkQueryService, times(1)).existsByUrl(member, url);
verify(linkCommandService, times(1)).saveLink(member, url, title, memo, storedImageUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ void shouldCreateLinkSuccessfully() throws Exception {
String originalImageUrl = "https://original.com/image.jpg";
String uploadedS3Url = "https://s3.amazonaws.com/bucket/links/uuid.jpg";

given(imageUploader.resolveStoredUrl(originalImageUrl)).willReturn(null);
given(imageUploader.uploadFromUrl(originalImageUrl)).willReturn(uploadedS3Url);

LinkCreateReq req = new LinkCreateReq(
Expand Down
45 changes: 45 additions & 0 deletions src/test/java/com/sofa/linkiving/infra/s3/S3ImageUploaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

import com.sofa.linkiving.domain.link.util.UrlValidator;
import com.sofa.linkiving.domain.link.error.LinkErrorCode;
import com.sofa.linkiving.global.error.exception.BusinessException;

import io.awspring.cloud.s3.S3Template;

@ExtendWith(MockitoExtension.class)
Expand All @@ -26,6 +30,7 @@ public class S3ImageUploaderTest {

private static final String BUCKET_NAME = "test-bucket";
private static final String REGION = "ap-northeast-2";
private static final String DEFAULT_IMAGE_URL = "https://example.com/default-image.jpg";
@InjectMocks
private S3ImageUploader s3ImageUploader;
@Mock
Expand All @@ -34,11 +39,14 @@ public class S3ImageUploaderTest {
private UrlConnectionFactory urlConnectionFactory;
@Mock
private URLConnection mockConnection;
@Mock
private UrlValidator urlValidator;

@BeforeEach
void setUp() {
ReflectionTestUtils.setField(s3ImageUploader, "bucketName", BUCKET_NAME);
ReflectionTestUtils.setField(s3ImageUploader, "region", REGION);
ReflectionTestUtils.setField(s3ImageUploader, "defaultImageUrl", DEFAULT_IMAGE_URL);
}

@Test
Expand All @@ -50,6 +58,8 @@ void shouldUploadImageWhenUrlIsValid() throws IOException {
// 1. 중복 검사 통과 (S3에 파일 없음)
given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false);

willDoNothing().given(urlValidator).validateSafeUrl(originalUrl);

// 2. Factory가 Mock Connection 반환
given(urlConnectionFactory.createConnection(originalUrl)).willReturn(mockConnection);

Expand Down Expand Up @@ -97,6 +107,7 @@ void shouldReturnDefaultImageUrlWhenNotImage() throws IOException {
String originalUrl = "https://example.com/document.pdf";

given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false);
willDoNothing().given(urlValidator).validateSafeUrl(originalUrl);
given(urlConnectionFactory.createConnection(originalUrl)).willReturn(mockConnection);

// ContentType을 이미지가 아닌 것으로 설정
Expand All @@ -119,6 +130,7 @@ void shouldReturnDefaultImageUrlWhenExceptionOccurs() throws IOException {
String originalUrl = "https://example.com/image.jpg";

given(s3Template.objectExists(eq(BUCKET_NAME), anyString())).willReturn(false);
willDoNothing().given(urlValidator).validateSafeUrl(originalUrl);

// 연결 생성 시 예외 발생하도록 설정
given(urlConnectionFactory.createConnection(originalUrl)).willThrow(new IOException("Connection Refused"));
Expand All @@ -141,4 +153,37 @@ void shouldReturnNullWhenUrlIsEmpty() {
assertThat(resultNull).isNull();
assertThat(resultEmpty).isNull();
}

@Test
@DisplayName("검증 실패 시 기본 이미지 URL을 반환한다")
void shouldReturnDefaultImageUrlWhenValidationFails() {
// given
String originalUrl = "http://127.0.0.1/image.jpg";

willThrow(new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP))
.given(urlValidator).validateSafeUrl(originalUrl);

// when
String result = s3ImageUploader.uploadFromUrl(originalUrl);

// then
assertThat(result).isEqualTo(DEFAULT_IMAGE_URL);
verify(urlConnectionFactory, never()).createConnection(anyString());
verify(s3Template, never()).upload(anyString(), anyString(), any(InputStream.class), any());
}

@Test
@DisplayName("저장소 URL이면 캐시 확인 후 바로 반환한다")
void shouldResolveStoredUrlWhenS3UrlProvided() {
// given
String s3Url = "https://" + BUCKET_NAME + ".s3." + REGION + ".amazonaws.com/links/test.jpg";

given(s3Template.objectExists(BUCKET_NAME, "links/test.jpg")).willReturn(true);

// when
String result = s3ImageUploader.resolveStoredUrl(s3Url);

// then
assertThat(result).isEqualTo(s3Url);
}
}
Loading