From 42425ed57133f0d376185a4b9ee788def2282196 Mon Sep 17 00:00:00 2001 From: Goder Date: Wed, 26 Aug 2026 02:35:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=81=ED=81=AC=20URL=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + .../domain/link/service/LinkService.java | 10 ++- .../domain/link/util/OgTagCrawler.java | 14 ++-- .../domain/link/util/UrlNormalizer.java | 84 +++++++++++++++++++ .../domain/link/service/LinkServiceTest.java | 22 +++-- .../domain/link/util/OgTagCrawlerTest.java | 67 +++++++++++++++ .../domain/link/util/UrlNormalizerTest.java | 67 +++++++++++++++ 7 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/sofa/linkiving/domain/link/util/UrlNormalizer.java create mode 100644 src/test/java/com/sofa/linkiving/domain/link/util/OgTagCrawlerTest.java create mode 100644 src/test/java/com/sofa/linkiving/domain/link/util/UrlNormalizerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d0a058..2c75d7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ For example, tag `v1.2.3` must have a `## [v1.2.3]` section. ## [Unreleased] +### Changed +- 링크 저장·중복 확인·메타데이터 수집 시 URL의 공백과 중복 프로토콜을 자동으로 정리하고, 프로토콜이 없으면 `https://`를 보완해 같은 링크를 일관되게 처리합니다. +- 기존 `http://` 주소와 path·query·fragment는 그대로 유지하며, 유효하지 않은 URL은 저장 전에 오류로 안내합니다. + ### Removed - BREAKING: 자체 회원가입·로그인 API(`POST /v1/member/signup`, `POST /v1/member/login`)를 제거했습니다. 인증 진입점은 Google OAuth(`/oauth2/**`)와 토큰 재발급(`/v1/auth/reissue`)으로 단일화되며, 제거된 두 경로는 공개 URL 목록에서도 빠져 401을 응답합니다. - 자체 로그인 전용 에러코드 `M-001`(중복 이메일)과 `M-003`(비밀번호 불일치)를 제거했습니다. Swagger User 태그의 회원가입·로그인 문서도 함께 사라집니다. diff --git a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java index fb34edba..6e9d17ef 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java +++ b/src/main/java/com/sofa/linkiving/domain/link/service/LinkService.java @@ -9,6 +9,7 @@ import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.enums.SummaryStatus; import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.domain.link.util.UrlNormalizer; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -22,13 +23,15 @@ public class LinkService { private final LinkCommandService linkCommandService; private final LinkQueryService linkQueryService; + private final UrlNormalizer urlNormalizer; public Link createLink(Member member, String url, String title, String memo, String imageUrl) { - if (linkQueryService.existsByUrl(member, url)) { + String normalizedUrl = urlNormalizer.normalize(url); + if (linkQueryService.existsByUrl(member, normalizedUrl)) { throw new BusinessException(LinkErrorCode.DUPLICATE_URL); } - Link link = linkCommandService.saveLink(member, url, title, memo, imageUrl); + Link link = linkCommandService.saveLink(member, normalizedUrl, title, memo, imageUrl); log.info("Link created - id={}, memberId={}", link.getId(), member.getId()); return link; @@ -104,7 +107,8 @@ public Long getLinkTotalCount(Member member) { } public Optional findLinkIdByUrl(Member member, String url) { - return linkQueryService.findIdByUrl(member, url); + String normalizedUrl = urlNormalizer.normalize(url); + return linkQueryService.findIdByUrl(member, normalizedUrl); } public void updateSummaryStatus(Long linkId, SummaryStatus status) { diff --git a/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java b/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java index 96f24c85..f4426753 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java +++ b/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java @@ -8,24 +8,24 @@ import com.sofa.linkiving.domain.link.dto.internal.OgTagDto; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Component +@RequiredArgsConstructor public class OgTagCrawler { private static final int TIMEOUT_MS = 5000; private final UrlValidator urlValidator; - - public OgTagCrawler(UrlValidator urlValidator) { - this.urlValidator = urlValidator; - } + private final UrlNormalizer urlNormalizer; public OgTagDto crawl(String url) { - urlValidator.validateSafeUrl(url); + String normalizedUrl = urlNormalizer.normalize(url); + urlValidator.validateSafeUrl(normalizedUrl); try { - Document document = Jsoup.connect(url) + Document document = Jsoup.connect(normalizedUrl) .timeout(TIMEOUT_MS) .userAgent("Mozilla/5.0") .get(); @@ -38,7 +38,7 @@ public OgTagDto crawl(String url) { .build(); } catch (IOException e) { - log.warn("OG 태그 크롤링 실패 - url={}, reason={}", url, e.getMessage()); + log.warn("OG 태그 크롤링 실패 - url={}, reason={}", normalizedUrl, e.getMessage()); return OgTagDto.EMPTY; } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/util/UrlNormalizer.java b/src/main/java/com/sofa/linkiving/domain/link/util/UrlNormalizer.java new file mode 100644 index 00000000..432c0374 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/util/UrlNormalizer.java @@ -0,0 +1,84 @@ +package com.sofa.linkiving.domain.link.util; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; + +@Component +public class UrlNormalizer { + + private static final Pattern SCHEME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://"); + private static final Pattern DUPLICATED_HTTP_SCHEME_PATTERN = Pattern.compile( + "^(https?://)(?:https?://)+", + Pattern.CASE_INSENSITIVE + ); + + public String normalize(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + throw new BusinessException(LinkErrorCode.INVALID_URL); + } + + String normalizedUrl = removeDuplicatedHttpScheme(rawUrl.strip()); + if (!SCHEME_PATTERN.matcher(normalizedUrl).find()) { + normalizedUrl = "https://" + normalizedUrl; + } + + return normalizeSchemeAndHost(normalizedUrl); + } + + private String removeDuplicatedHttpScheme(String url) { + Matcher matcher = DUPLICATED_HTTP_SCHEME_PATTERN.matcher(url); + return matcher.find() ? matcher.replaceFirst(matcher.group(1)) : url; + } + + private String normalizeSchemeAndHost(String url) { + try { + URI uri = new URI(url); + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new BusinessException(LinkErrorCode.INVALID_URL_PROTOCOL); + } + + String host = uri.getHost(); + if (host == null || host.isBlank() || !host.contains(".")) { + throw new BusinessException(LinkErrorCode.INVALID_URL); + } + + return rebuildUrl(uri, scheme.toLowerCase(Locale.ROOT), host.toLowerCase(Locale.ROOT)); + } catch (URISyntaxException exception) { + throw new BusinessException(LinkErrorCode.INVALID_URL); + } + } + + private String rebuildUrl(URI uri, String scheme, String host) { + String authority = uri.getRawAuthority(); + int hostStart = authority.lastIndexOf('@') + 1; + int hostEnd = authority.indexOf(':', hostStart); + if (hostEnd == -1) { + hostEnd = authority.length(); + } + + String normalizedAuthority = authority.substring(0, hostStart) + + host + + authority.substring(hostEnd); + StringBuilder normalizedUrl = new StringBuilder(scheme) + .append("://") + .append(normalizedAuthority) + .append(uri.getRawPath()); + + if (uri.getRawQuery() != null) { + normalizedUrl.append('?').append(uri.getRawQuery()); + } + if (uri.getRawFragment() != null) { + normalizedUrl.append('#').append(uri.getRawFragment()); + } + return normalizedUrl.toString(); + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java index 08f7a176..59eee072 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/service/LinkServiceTest.java @@ -21,6 +21,7 @@ import com.sofa.linkiving.domain.link.enums.SummaryStatus; import com.sofa.linkiving.domain.link.error.LinkErrorCode; import com.sofa.linkiving.domain.link.error.SummaryErrorCode; +import com.sofa.linkiving.domain.link.util.UrlNormalizer; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.error.exception.BusinessException; @@ -37,6 +38,9 @@ class LinkServiceTest { @Mock private LinkQueryService linkQueryService; + @Mock + private UrlNormalizer urlNormalizer; + @Test @DisplayName("회원 정보 없이 링크 ID만으로 링크를 단건 조회할 수 있다") void shouldGetLinkByIdOnly() { @@ -83,6 +87,7 @@ void shouldCreateLink() { .title("테스트 링크") .build(); + given(urlNormalizer.normalize(" example.com ")).willReturn("https://example.com"); given(linkQueryService.existsByUrl(member, "https://example.com")).willReturn(false); given(linkCommandService.saveLink(any(), any(), any(), any(), any())) .willReturn(link); @@ -90,7 +95,7 @@ void shouldCreateLink() { // when Link save = linkService.createLink( member, - "https://example.com", + " example.com ", "테스트 링크", "메모", null @@ -100,7 +105,7 @@ void shouldCreateLink() { assertThat(save).isNotNull(); assertThat(save.getUrl()).isEqualTo("https://example.com"); verify(linkQueryService, times(1)).existsByUrl(member, "https://example.com"); - verify(linkCommandService, times(1)).saveLink(any(), any(), any(), any(), any()); + verify(linkCommandService, times(1)).saveLink(member, "https://example.com", "테스트 링크", "메모", null); } @Test @@ -111,12 +116,13 @@ void shouldThrowExceptionWhenDuplicateUrl() { .email("test@example.com") .build(); + given(urlNormalizer.normalize("https://https://example.com")).willReturn("https://example.com"); given(linkQueryService.existsByUrl(member, "https://example.com")).willReturn(true); // when & then assertThatThrownBy(() -> linkService.createLink( member, - "https://example.com", + "https://https://example.com", "테스트 링크", null, null @@ -346,10 +352,11 @@ void shouldCheckDuplicate() { .email("test@example.com") .build(); + given(urlNormalizer.normalize("example.com")).willReturn("https://example.com"); given(linkQueryService.findIdByUrl(member, "https://example.com")).willReturn(java.util.Optional.of(123L)); // when - Optional result = linkService.findLinkIdByUrl(member, "https://example.com"); + Optional result = linkService.findLinkIdByUrl(member, "example.com"); // then assertThat(result).isPresent(); @@ -365,14 +372,15 @@ void shouldCheckDuplicateNotExists() { .email("test@example.com") .build(); - given(linkQueryService.findIdByUrl(member, "https://example.com")).willReturn(java.util.Optional.empty()); + given(urlNormalizer.normalize("www.example.com")).willReturn("https://www.example.com"); + given(linkQueryService.findIdByUrl(member, "https://www.example.com")).willReturn(java.util.Optional.empty()); // when - Optional result = linkService.findLinkIdByUrl(member, "https://example.com"); + Optional result = linkService.findLinkIdByUrl(member, "www.example.com"); // then assertThat(result).isEmpty(); - verify(linkQueryService, times(1)).findIdByUrl(member, "https://example.com"); + verify(linkQueryService, times(1)).findIdByUrl(member, "https://www.example.com"); } @Test diff --git a/src/test/java/com/sofa/linkiving/domain/link/util/OgTagCrawlerTest.java b/src/test/java/com/sofa/linkiving/domain/link/util/OgTagCrawlerTest.java new file mode 100644 index 00000000..85f8b9d1 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/util/OgTagCrawlerTest.java @@ -0,0 +1,67 @@ +package com.sofa.linkiving.domain.link.util; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +class OgTagCrawlerTest { + + @Test + @DisplayName("메타데이터 수집 전 정규화된 URL을 SSRF 검증한다") + void validateNormalizedUrlBeforeCrawling() { + UrlValidator urlValidator = mock(UrlValidator.class); + UrlNormalizer urlNormalizer = mock(UrlNormalizer.class); + OgTagCrawler ogTagCrawler = new OgTagCrawler(urlValidator, urlNormalizer); + String rawUrl = " 192.168.0.1 "; + String normalizedUrl = "https://192.168.0.1"; + + given(urlNormalizer.normalize(rawUrl)).willReturn(normalizedUrl); + willThrow(new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP)) + .given(urlValidator).validateSafeUrl(normalizedUrl); + + assertThatThrownBy(() -> ogTagCrawler.crawl(rawUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + + then(urlNormalizer).should().normalize(rawUrl); + then(urlValidator).should().validateSafeUrl(normalizedUrl); + } + + @Test + @DisplayName("정규화된 URL로 메타데이터를 요청한다") + void crawlNormalizedUrl() throws IOException, InterruptedException { + try (MockWebServer mockWebServer = new MockWebServer()) { + mockWebServer.enqueue(new MockResponse().setBody(""" + + + + """)); + mockWebServer.start(); + + UrlValidator urlValidator = mock(UrlValidator.class); + UrlNormalizer urlNormalizer = mock(UrlNormalizer.class); + OgTagCrawler ogTagCrawler = new OgTagCrawler(urlValidator, urlNormalizer); + String rawUrl = "naver.com"; + String normalizedUrl = mockWebServer.url("/article?Q=Abc").toString(); + given(urlNormalizer.normalize(rawUrl)).willReturn(normalizedUrl); + + assertThat(ogTagCrawler.crawl(rawUrl).title()).isEqualTo("normalized target"); + RecordedRequest request = mockWebServer.takeRequest(1, TimeUnit.SECONDS); + assertThat(request).isNotNull(); + assertThat(request.getPath()).isEqualTo("/article?Q=Abc"); + then(urlValidator).should().validateSafeUrl(normalizedUrl); + } + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/util/UrlNormalizerTest.java b/src/test/java/com/sofa/linkiving/domain/link/util/UrlNormalizerTest.java new file mode 100644 index 00000000..a1889fd3 --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/util/UrlNormalizerTest.java @@ -0,0 +1,67 @@ +package com.sofa.linkiving.domain.link.util; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; + +class UrlNormalizerTest { + + private final UrlNormalizer urlNormalizer = new UrlNormalizer(); + + @ParameterizedTest + @CsvSource({ + "naver.com, https://naver.com", + "www.naver.com, https://www.naver.com", + "https://www.naver.com, https://www.naver.com", + "http://old-site.com, http://old-site.com", + "https://https://naver.com, https://naver.com", + "' https://naver.com ', https://naver.com", + "'https://naver.com/path?q=abc#section', 'https://naver.com/path?q=abc#section'", + "Naver.com, https://naver.com", + "HTTPS://NAVER.COM, https://naver.com", + "'https://Naver.com/Path?Q=Abc#Frag', 'https://naver.com/Path?Q=Abc#Frag'" + }) + @DisplayName("URL을 규칙에 맞게 정규화한다") + void normalizeUrl(String input, String expected) { + assertThat(urlNormalizer.normalize(input)).isEqualTo(expected); + } + + @Test + @DisplayName("앞뒤 줄바꿈을 제거한다") + void removeLeadingAndTrailingLineBreaks() { + assertThat(urlNormalizer.normalize("\nhttps://naver.com\r\n")) + .isEqualTo("https://naver.com"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "안녕하세요", "https://localhost", "https://naver"}) + @DisplayName("빈 값이거나 점이 없는 도메인은 거부한다") + void rejectInvalidUrl(String input) { + assertThatThrownBy(() -> urlNormalizer.normalize(input)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL); + } + + @Test + @DisplayName("null URL은 거부한다") + void rejectNullUrl() { + assertThatThrownBy(() -> urlNormalizer.normalize(null)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL); + } + + @Test + @DisplayName("HTTP와 HTTPS 외의 프로토콜은 거부한다") + void rejectUnsupportedProtocol() { + assertThatThrownBy(() -> urlNormalizer.normalize("ftp://example.com")) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PROTOCOL); + } +}