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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 태그의 회원가입·로그인 문서도 함께 사라집니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -104,7 +107,8 @@ public Long getLinkTotalCount(Member member) {
}

public Optional<Long> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,6 +38,9 @@ class LinkServiceTest {
@Mock
private LinkQueryService linkQueryService;

@Mock
private UrlNormalizer urlNormalizer;

@Test
@DisplayName("회원 정보 없이 링크 ID만으로 링크를 단건 조회할 수 있다")
void shouldGetLinkByIdOnly() {
Expand Down Expand Up @@ -83,14 +87,15 @@ 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);

// when
Link save = linkService.createLink(
member,
"https://example.com",
" example.com ",
"테스트 링크",
"메모",
null
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Long> result = linkService.findLinkIdByUrl(member, "https://example.com");
Optional<Long> result = linkService.findLinkIdByUrl(member, "example.com");

// then
assertThat(result).isPresent();
Expand All @@ -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<Long> result = linkService.findLinkIdByUrl(member, "https://example.com");
Optional<Long> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
goder-0 marked this conversation as resolved.

@Test
@DisplayName("정규화된 URL로 메타데이터를 요청한다")
void crawlNormalizedUrl() throws IOException, InterruptedException {
try (MockWebServer mockWebServer = new MockWebServer()) {
mockWebServer.enqueue(new MockResponse().setBody("""
<html>
<head><meta property="og:title" content="normalized target"></head>
</html>
"""));
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading