diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index 61933dbb..630fc028 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -93,7 +93,7 @@ ai/ |-------|-------------|------| | `ai.analyze.resume` | `analyze.resume` | 본 구현 (PDF → MD) | | `ai.analyze.repository` | `analyze.repository` | 본 구현 (GitHub README + tree + 소스 sampling) | -| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura) | +| `ai.analyze.web` | `analyze.web` | 본 구현 (URL → trafilatura, SSRF 가드 `analyzer/sources/url_guard.py`) | | `ai.analyze.cover_letter` | `analyze.cover_letter` | 본 구현 (자소서 문항 inline 텍스트 → MD, `TextSourceExtractor`) | | `ai.generate.questions` | `generate.questions` | 본 구현 (Pro 모델, 질문 풀 생성, US-18) | | `ai.generate.followup` | `generate.followup` | 본 구현 (Flash 모델, 답변 평가+꼬리질문, US-19) | @@ -318,6 +318,12 @@ docker run --env-file .env -p 8000:8000 stackup-ai - FastAPI 부트스트랩 + 헬스체크 - 분석 consumer 본 구현 — `analyze.resume` / `analyze.repository` / `analyze.web`: - PDF·GitHub Repo·웹 URL 소스 추출 추상화 (`analyzer/sources/`) + - **웹 URL 은 SSRF 가드 필수** (`analyzer/sources/url_guard.py`): 이 프로세스는 docker 네트워크에서 + Core·PG·RabbitMQ·MinIO 에 닿고 배포 호스트에서는 클라우드 메타데이터(169.254.169.254)에도 닿는다. + 스킴·userinfo 검사 + 호스트를 **해석한 주소**로 사설/루프백/링크로컬/멀티캐스트/예약/IPv6 + unique-local 차단. `follow_redirects` 는 끄고 홉마다 재검증(상대 Location 은 절대화 후 검사, + 5홉 제한). Playwright 렌더 폴백도 검증된 최종 URL 로 실행. Core 의 `WebResumeUrlValidator` 는 + 첫 관문이고 DNS rebinding·리다이렉트로 우회되므로 소켓을 여는 이쪽 검사가 실질 방어선이다. - LLM 분석 (`chain/document_analysis_chain.py`, Gemini Pro + Pydantic 출력 파서) - 분석 MD를 스토리지에 저장 - `callback.analysis` 발행 (status `ANALYZED` / `FAILED`, retriable 플래그 포함) diff --git a/ai/src/ai_server/analyzer/sources/url_guard.py b/ai/src/ai_server/analyzer/sources/url_guard.py new file mode 100644 index 00000000..32364447 --- /dev/null +++ b/ai/src/ai_server/analyzer/sources/url_guard.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import ipaddress +import socket +from typing import Callable +from urllib.parse import urlsplit + +import structlog + +log = structlog.get_logger(__name__) + +# host -> IP 문자열 목록. 테스트에서 실제 DNS 를 타지 않도록 주입 가능하게 둔다. +Resolver = Callable[[str], list[str]] + +_ALLOWED_SCHEMES = ("http", "https") +_REDIRECT_STATUSES = (301, 302, 303, 307, 308) + + +class BlockedUrlError(Exception): + """SSRF 가드가 막은 URL.""" + + def __init__(self, *, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +def default_resolver(host: str) -> list[str]: + infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) + return [info[4][0] for info in infos] + + +def is_blocked_address(raw_ip: str) -> bool: + """루프백·사설·링크로컬(클라우드 메타데이터)·멀티캐스트·예약·와일드카드 대역인지.""" + try: + ip = ipaddress.ip_address(raw_ip) + except ValueError: + # 해석할 수 없는 주소는 신뢰하지 않는다. + return True + return ( + ip.is_private # 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, fc00::/7 + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ) + + +def assert_public_http_url(url: str, *, resolver: Resolver = default_resolver) -> None: + """공개 http(s) 주소가 아니면 :class:`BlockedUrlError`. + + 이 프로세스는 docker 네트워크 안에서 Core·PostgreSQL·RabbitMQ·MinIO 에 닿고, 배포 + 호스트에서는 클라우드 메타데이터(169.254.169.254)에도 닿는다. Core 에도 같은 검증이 + 있지만(WebResumeUrlValidator) DNS rebinding·리다이렉트로 우회되므로, **실제 소켓을 여는 + 직전에** 여기서 한 번 더 확인해야 막힌다. + """ + parts = urlsplit(url.strip()) + scheme = (parts.scheme or "").lower() + if scheme not in _ALLOWED_SCHEMES: + raise BlockedUrlError( + code="INVALID_WEB_URL", + message=f"locator must be http(s) URL, got: {url!r}", + ) + # user:pass@host 는 파서 차이를 이용한 호스트 위장에 쓰인다. + if parts.username or parts.password: + raise BlockedUrlError( + code="INVALID_WEB_URL", + message="URL 에 사용자 정보를 포함할 수 없음", + ) + host = parts.hostname + if not host: + raise BlockedUrlError(code="INVALID_WEB_URL", message="URL 에 호스트가 없음") + + try: + addresses = resolver(host) + except OSError as exc: + raise BlockedUrlError( + code="WEB_HOST_UNRESOLVED", + message=f"호스트를 해석할 수 없음: {host}", + ) from exc + if not addresses: + raise BlockedUrlError( + code="WEB_HOST_UNRESOLVED", + message=f"호스트를 해석할 수 없음: {host}", + ) + + for address in addresses: + if is_blocked_address(address): + # 어떤 내부 주소로 해석됐는지는 로그에만 남긴다. + log.warning("web.url.blocked", host=host, resolved=address) + raise BlockedUrlError( + code="BLOCKED_WEB_URL", + message="내부 네트워크 주소는 가져올 수 없음", + ) + + +def is_redirect(status_code: int) -> bool: + return status_code in _REDIRECT_STATUSES diff --git a/ai/src/ai_server/analyzer/sources/web.py b/ai/src/ai_server/analyzer/sources/web.py index 130d4590..d57c41e9 100644 --- a/ai/src/ai_server/analyzer/sources/web.py +++ b/ai/src/ai_server/analyzer/sources/web.py @@ -7,6 +7,13 @@ import trafilatura from ai_server.analyzer.sources.base import ExtractedSource, SourceExtractor +from ai_server.analyzer.sources.url_guard import ( + BlockedUrlError, + Resolver, + assert_public_http_url, + default_resolver, + is_redirect, +) log = structlog.get_logger(__name__) @@ -28,20 +35,19 @@ def __init__( max_html_bytes: int = 2_000_000, enable_render_fallback: bool = True, client: httpx.AsyncClient | None = None, + resolver: Resolver = default_resolver, + max_redirects: int = 5, ) -> None: self._timeout_sec = timeout_sec self._max_html_bytes = max_html_bytes self._enable_render_fallback = enable_render_fallback self._client = client + self._resolver = resolver + self._max_redirects = max_redirects async def extract(self, locator: str) -> ExtractedSource: url = locator.strip() - if not (url.startswith("http://") or url.startswith("https://")): - raise WebFetchError( - code="INVALID_WEB_URL", - message=f"locator must be http(s) URL, got: {locator!r}", - retriable=False, - ) + self._require_public(url) html, final_url, content_type = await self._fetch_html(url) text = await asyncio.to_thread(_extract_main_text, html, final_url) @@ -49,7 +55,7 @@ async def extract(self, locator: str) -> ExtractedSource: # 본문이 비면 JS 렌더링 SPA(React 포폴 등)일 가능성 → Playwright 로 렌더 후 재추출. if not text.strip() and self._enable_render_fallback: - rendered_html = await self._render(url) + rendered_html = await self._render(final_url) if rendered_html: html = rendered_html text = await asyncio.to_thread(_extract_main_text, html, final_url) @@ -100,12 +106,26 @@ async def _render(self, url: str) -> str | None: log.warning("web.render.failed", url=url, error=str(exc)) return None + # SSRF 가드. Core 에서도 검증하지만 DNS rebinding·리다이렉트로 우회되므로 + # 실제 요청을 보내기 직전에(그리고 리다이렉트 홉마다) 다시 확인한다. + def _require_public(self, url: str) -> None: + try: + assert_public_http_url(url, resolver=self._resolver) + except BlockedUrlError as err: + raise WebFetchError( + code=err.code, + message=err.message, + retriable=False, + ) from err + async def _fetch_html(self, url: str) -> tuple[str, str, str]: if self._client is not None: return await self._do_fetch(self._client, url) async with httpx.AsyncClient( timeout=self._timeout_sec, - follow_redirects=True, + # 자동 추적을 끄고 홉마다 목적지를 검증한다 — 공개 URL 이 내부 주소로 + # 리다이렉트하는 경로를 막기 위해. + follow_redirects=False, headers={ "User-Agent": "StackUp-AI/1.0 (+resume web extractor)", "Accept": "text/html,application/xhtml+xml", @@ -118,14 +138,29 @@ async def _do_fetch( client: httpx.AsyncClient, url: str, ) -> tuple[str, str, str]: - try: - resp = await client.get(url) - except httpx.HTTPError as exc: + current = url + for _ in range(self._max_redirects + 1): + try: + resp = await client.get(current) + except httpx.HTTPError as exc: + raise WebFetchError( + code="WEB_FETCH_FAILED", + message=f"HTTP 요청 실패: {exc}", + retriable=True, + ) from exc + + location = resp.headers.get("location") + if not (is_redirect(resp.status_code) and location): + break + # 상대 Location 도 절대 URL 로 만든 뒤 검증한다. + current = str(httpx.URL(current).join(location)) + self._require_public(current) + else: raise WebFetchError( - code="WEB_FETCH_FAILED", - message=f"HTTP 요청 실패: {exc}", - retriable=True, - ) from exc + code="WEB_TOO_MANY_REDIRECTS", + message=f"리다이렉트가 한도({self._max_redirects})를 초과", + retriable=False, + ) if resp.status_code >= 400: raise WebFetchError( diff --git a/ai/tests/test_web_extractor.py b/ai/tests/test_web_extractor.py index 93619f02..78bc4f03 100644 --- a/ai/tests/test_web_extractor.py +++ b/ai/tests/test_web_extractor.py @@ -6,6 +6,13 @@ from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor +# DNS 를 타지 않는다 — example.com 계열은 공개 IP, 그 외 리터럴은 그대로. +def _fake_resolver(host: str) -> list[str]: + if host.endswith("example.com"): + return ["93.184.216.34"] + return [host] + + def _make_client( *, status: int = 200, @@ -39,7 +46,7 @@ async def test_extract_returns_main_body_text() -> None: "" ) client = _make_client(body=html) - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) result = await extractor.extract("https://example.com/r") assert result.source_type == "WEB" @@ -51,7 +58,7 @@ async def test_extract_returns_main_body_text() -> None: @pytest.mark.asyncio async def test_rejects_non_http_locator() -> None: - extractor = WebSourceExtractor(client=_make_client()) + extractor = WebSourceExtractor(client=_make_client(), resolver=_fake_resolver) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("ftp://example.com/x") assert exc_info.value.code == "INVALID_WEB_URL" @@ -61,7 +68,7 @@ async def test_rejects_non_http_locator() -> None: @pytest.mark.asyncio async def test_raises_on_http_error_status() -> None: client = _make_client(status=503) - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "WEB_HTTP_STATUS" @@ -71,7 +78,7 @@ async def test_raises_on_http_error_status() -> None: @pytest.mark.asyncio async def test_raises_on_4xx_as_non_retriable() -> None: client = _make_client(status=404) - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "WEB_HTTP_STATUS" @@ -81,7 +88,7 @@ async def test_raises_on_4xx_as_non_retriable() -> None: @pytest.mark.asyncio async def test_rejects_non_html_content_type() -> None: client = _make_client(content_type="application/pdf") - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "WEB_NOT_HTML" @@ -91,7 +98,9 @@ async def test_rejects_non_html_content_type() -> None: async def test_rejects_oversized_html() -> None: big = b"" + b"a" * 1024 + b"" client = _make_client(body=big) - extractor = WebSourceExtractor(client=client, max_html_bytes=512) + extractor = WebSourceExtractor( + client=client, max_html_bytes=512, resolver=_fake_resolver + ) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "WEB_HTML_TOO_LARGE" @@ -100,7 +109,9 @@ async def test_rejects_oversized_html() -> None: @pytest.mark.asyncio async def test_raises_on_empty_body() -> None: client = _make_client(body="") - extractor = WebSourceExtractor(client=client, enable_render_fallback=False) + extractor = WebSourceExtractor( + client=client, enable_render_fallback=False, resolver=_fake_resolver + ) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "EMPTY_WEB_BODY" @@ -111,7 +122,7 @@ async def test_raises_on_empty_body() -> None: async def test_empty_body_falls_back_to_render() -> None: # 1차 fetch = JS 셸(본문 없음) → 렌더 폴백으로 본문 확보 client = _make_client(body='
') - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) rendered_html = ( "

김OO

" "

프론트엔드 개발자. React 포트폴리오.

" @@ -127,7 +138,7 @@ async def test_empty_body_falls_back_to_render() -> None: @pytest.mark.asyncio async def test_render_fallback_returning_none_raises_empty() -> None: client = _make_client(body='
') - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) extractor._render = AsyncMock(return_value=None) # 렌더 실패/불가 with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/spa") @@ -137,7 +148,7 @@ async def test_render_fallback_returning_none_raises_empty() -> None: @pytest.mark.asyncio async def test_raises_on_httpx_error_as_retriable() -> None: client = _make_client(raise_exc=httpx.ConnectError("dns fail")) - extractor = WebSourceExtractor(client=client) + extractor = WebSourceExtractor(client=client, resolver=_fake_resolver) with pytest.raises(WebFetchError) as exc_info: await extractor.extract("https://example.com/r") assert exc_info.value.code == "WEB_FETCH_FAILED" diff --git a/ai/tests/test_web_url_guard.py b/ai/tests/test_web_url_guard.py new file mode 100644 index 00000000..e4bc82b8 --- /dev/null +++ b/ai/tests/test_web_url_guard.py @@ -0,0 +1,172 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from ai_server.analyzer.sources.url_guard import ( + BlockedUrlError, + assert_public_http_url, + is_blocked_address, +) +from ai_server.analyzer.sources.web import WebFetchError, WebSourceExtractor + +# DNS 를 타지 않는 해석기. example.com 계열만 공개 IP, 나머지는 입력을 그대로 IP 로 본다. +_PUBLIC = "93.184.216.34" + + +def _resolver(host: str) -> list[str]: + if host.endswith("example.com"): + return [_PUBLIC] + if host == "rebind.test": + return ["10.1.2.3"] # 공개 도메인이 사설 IP 로 해석되는 경우 + return [host] + + +def _guard(url: str) -> None: + assert_public_http_url(url, resolver=_resolver) + + +class TestBlockedAddress: + @pytest.mark.parametrize( + "ip", + [ + "127.0.0.1", + "10.0.0.5", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", # 클라우드 메타데이터 + "0.0.0.0", + "::1", + "fc00::1", + "224.0.0.1", # 멀티캐스트 + "not-an-ip", # 해석 불가 → 신뢰하지 않는다 + ], + ) + def test_blocks_non_public(self, ip: str) -> None: + assert is_blocked_address(ip) is True + + @pytest.mark.parametrize("ip", [_PUBLIC, "1.1.1.1", "2606:4700::1111"]) + def test_allows_public(self, ip: str) -> None: + assert is_blocked_address(ip) is False + + +class TestAssertPublicHttpUrl: + def test_accepts_public_https(self) -> None: + _guard("https://example.com/portfolio") + + @pytest.mark.parametrize( + "url", + [ + "file:///etc/passwd", + "ftp://example.com/x", + "gopher://example.com/", + "example.com", + "//example.com/x", + ], + ) + def test_rejects_bad_scheme(self, url: str) -> None: + with pytest.raises(BlockedUrlError) as exc: + _guard(url) + assert exc.value.code == "INVALID_WEB_URL" + + def test_rejects_userinfo(self) -> None: + with pytest.raises(BlockedUrlError) as exc: + _guard("https://evil@example.com/") + assert exc.value.code == "INVALID_WEB_URL" + + @pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1:8080/api/internal/documents/1", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.5/", + "http://[::1]/", + ], + ) + def test_rejects_internal_addresses(self, url: str) -> None: + with pytest.raises(BlockedUrlError) as exc: + _guard(url) + assert exc.value.code == "BLOCKED_WEB_URL" + + # 이름이 아니라 해석된 주소로 판단해야 막힌다. + def test_rejects_public_name_resolving_to_private(self) -> None: + with pytest.raises(BlockedUrlError) as exc: + _guard("https://rebind.test/") + assert exc.value.code == "BLOCKED_WEB_URL" + + def test_rejects_unresolvable_host(self) -> None: + def failing(host: str) -> list[str]: + raise OSError("nope") + + with pytest.raises(BlockedUrlError) as exc: + assert_public_http_url("https://nope.invalid/", resolver=failing) + assert exc.value.code == "WEB_HOST_UNRESOLVED" + + +def _redirect_client(*hops: tuple[int, str | None]) -> MagicMock: + """hop 목록을 순서대로 반환하는 클라이언트. (status, location) — location=None 이면 본문 응답.""" + responses = [] + for status, location in hops: + resp = MagicMock(spec=httpx.Response) + resp.status_code = status + body = ( + "

본문 텍스트가 충분히 길게 있습니다.

" + "
" + ) + resp.headers = {"content-type": "text/html"} | ( + {"location": location} if location else {} + ) + resp.text = body + resp.content = body.encode("utf-8") + resp.url = "https://example.com/final" + responses.append(resp) + client = MagicMock() + client.get = AsyncMock(side_effect=responses) + return client + + +class TestRedirectGuard: + # 공개 URL → 내부 주소 리다이렉트. 자동 추적을 켜두면 내부 응답을 그대로 요약해 돌려준다. + @pytest.mark.asyncio + async def test_blocks_redirect_to_internal_address(self) -> None: + client = _redirect_client((302, "http://169.254.169.254/latest/meta-data/")) + extractor = WebSourceExtractor(client=client, resolver=_resolver) + + with pytest.raises(WebFetchError) as exc: + await extractor.extract("https://example.com/start") + + assert exc.value.code == "BLOCKED_WEB_URL" + assert exc.value.retriable is False + + @pytest.mark.asyncio + async def test_follows_public_redirect(self) -> None: + client = _redirect_client((302, "https://example.com/final"), (200, None)) + extractor = WebSourceExtractor(client=client, resolver=_resolver) + + result = await extractor.extract("https://example.com/start") + + assert "본문 텍스트" in result.text + + # 상대 Location 도 절대 URL 로 합친 뒤 검증해야 한다. + @pytest.mark.asyncio + async def test_resolves_relative_location_before_checking(self) -> None: + client = _redirect_client((302, "/moved"), (200, None)) + extractor = WebSourceExtractor(client=client, resolver=_resolver) + + result = await extractor.extract("https://example.com/start") + + assert "본문 텍스트" in result.text + assert client.get.await_args_list[1].args[0] == "https://example.com/moved" + + @pytest.mark.asyncio + async def test_rejects_redirect_loop(self) -> None: + hops = [(302, "https://example.com/loop")] * 8 + client = _redirect_client(*hops) + extractor = WebSourceExtractor( + client=client, resolver=_resolver, max_redirects=3 + ) + + with pytest.raises(WebFetchError) as exc: + await extractor.extract("https://example.com/start") + + assert exc.value.code == "WEB_TOO_MANY_REDIRECTS" diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index fadf6429..fff53edf 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -85,7 +85,7 @@ com.stackup.stackup.{domain}/ | `user` | 사용자 CRUD, 회원 탈퇴, 프로필 조회 | US-02, US-04 | | `user.consent` | 개인정보처리동의 기록·조회·철회 | US-03 | | `github` | GitHub API 연동, 레포 목록/등록/메타 동기화 | US-07, US-08 | -| `resume` | 이력서 업로드(S3)·메타 저장·목록·삭제 | US-05, US-06 | +| `resume` | 이력서 업로드(S3)·메타 저장·목록·삭제 + **웹 이력서(URL) 등록**(`file_type=WEB`, `source_url`; SSRF 가드 `WebResumeUrlValidator`) | US-05, US-06, US-09 | | `coverletter` | 자소서(공채) 문항별 텍스트 입력·메타 저장·목록·삭제. inline 텍스트→`analyze.cover_letter`→분석 파이프라인 재사용. AnalyzedDocument 에 `cover_letter_id` 다형성 FK 추가 | — | | `document` | 분석 문서(이력서/레포/자소서 공통) 메타 + S3 경로 | US-09~12 | | `session` | 면접 세션·메시지·피드백 (가장 큰 도메인) | US-13~20, US-24~27 | @@ -427,6 +427,19 @@ docker compose up -d 로 다음 일반질문으로 진행 — 턴이 사라진 것처럼 보이지 않으면서 면접은 멈추지 않는다. - **문장 단위 TTS 세그먼트 프록시 본 구현 (Part B)**: `InterviewMessageService.streamAudioSegment` + `GET /api/sessions/{sid}/messages/{mid}/audio/segments/{seq}?ext=`. AI 가 휘발성으로 쓴 라이브 세그먼트를 규칙(`interview/tts/{sid}/{mid}/seg-{seq}.{ext}`)으로 재구성해 프록시(DB 미기록). 소유권+ext 화이트리스트+seq>=0 검증으로 임의 키 노출 차단. - AI 호출 로깅 (US-30) 본 구현: `/api/internal/ai-logs` + `ai_request_logs` INSERT +- **웹 이력서(URL) 본 구현 (US-09)**: `POST /api/resumes/web { url }`. AI 서버에 웹 분석이 이미 + 완성돼 있었는데(`analyze.web` consumer) Core 발행부가 없어 반쪽이던 걸 배선했다. `docs/messaging.md §5.3` + 대로 **resume 도메인을 재사용** — V24 로 `resumes` 에 `file_type='WEB'`·`source_url` 추가(+`file_path` + nullable, 타입별 필수 locator CHECK). 흐름은 PDF 와 대칭: + `ResumeService.registerWeb` → `WebResumeRegisteredEvent` → `WebResumeAnalysisEventListener` → + `AnalysisRequestService.requestWebResumeAnalysis`(AnalyzedDocument 생성) → AFTER_COMMIT `analyze.web` 발행. + 콜백은 `AnalysisCallbackService` 가 `context.documentId` 로 처리하므로 **무변경 재사용**. + - **SSRF 가드 필수** (`WebResumeUrlValidator`): 사용자 URL 을 AI 서버가 그대로 fetch 하고, AI 는 docker + 네트워크에서 Core·PG·RabbitMQ·MinIO 에 닿는다. http(s) 만 허용 + userinfo 거부 + 호스트를 **해석한 + 주소**로 사설/루프백/링크로컬/멀티캐스트/IPv6 unique-local 차단(이름이 아니라 주소로 판단하므로 사설 + IP 로 해석되는 공개 도메인도 막힌다). 거부 응답에 내부 주소는 노출하지 않는다. DNS 해석기는 주입 + 가능(테스트가 네트워크 미의존). 여긴 첫 관문이고 실질 방어선은 AI 쪽 `url_guard.py` 다. + - 같은 URL 재등록은 409(`RESUME_URL_DUPLICATE`) — 임베딩 중복으로 질문이 쏠리는 것 방지. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/openapi.json b/backend/openapi.json index c7649c59..1a25bc37 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -855,6 +855,66 @@ } } }, + "/api/resumes/web" : { + "post" : { + "tags" : [ "Resumes" ], + "summary" : "웹 이력서(URL) 등록 + 분석 트리거 (US-09)", + "description" : "포트폴리오·블로그·노션 등 공개 URL 을 이력서 자료로 등록한다. 파일 업로드 없이 URL 만 저장하고, 본문 추출·요약·임베딩은 AI 서버가 수행(analyze.web). 결과는 /realtime/stream/me (DOC_STATE) 로 통지된다. http·https 공개 주소만 허용(내부망 차단).", + "operationId" : "registerWebResume", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/WebResumeCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "등록 + 분석 트리거 성공", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "400" : { + "description" : "URL 형식 오류 / 비-http(s) / 내부망 주소", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "401" : { + "description" : "인증 실패", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + }, + "409" : { + "description" : "이미 등록된 URL", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ResumeResponse" + } + } + } + } + } + } + }, "/api/repositories" : { "get" : { "tags" : [ "Repositories" ], @@ -3130,12 +3190,15 @@ }, "fileType" : { "type" : "string", - "enum" : [ "PDF" ] + "enum" : [ "PDF", "WEB" ] }, "fileSize" : { "type" : "integer", "format" : "int64" }, + "sourceUrl" : { + "type" : "string" + }, "status" : { "type" : "string", "enum" : [ "PENDING", "ANALYZING", "ANALYZED", "FAILED" ] @@ -3150,6 +3213,17 @@ } } }, + "WebResumeCreateRequest" : { + "type" : "object", + "properties" : { + "url" : { + "type" : "string", + "maxLength" : 2000, + "minLength" : 0 + } + }, + "required" : [ "url" ] + }, "RegisterRepositoryRequest" : { "type" : "object", "properties" : { diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java index 7b44bd0c..f885a0ab 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -65,6 +65,7 @@ public record Queues( public record Names( @NotBlank String aiAnalyzeResume, @NotBlank String aiAnalyzeRepository, + @NotBlank String aiAnalyzeWeb, @NotBlank String aiAnalyzeCoverLetter, @NotBlank String aiGenerateQuestions, @NotBlank String aiGenerateFollowup, @@ -83,6 +84,7 @@ public record Names( public record RoutingKeyProperties( @NotBlank String analyzeResume, @NotBlank String analyzeRepository, + @NotBlank String analyzeWeb, @NotBlank String analyzeCoverLetter, @NotBlank String generateQuestions, @NotBlank String generateFollowup, diff --git a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java index 2a50c9e6..d89ea2dd 100644 --- a/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java +++ b/backend/src/main/java/com/stackup/stackup/common/exception/ApiErrorCode.java @@ -19,6 +19,8 @@ public enum ApiErrorCode { RESUME_EMPTY_FILE(HttpStatus.BAD_REQUEST, "빈 파일은 업로드할 수 없습니다."), RESUME_NOT_FOUND(HttpStatus.NOT_FOUND, "이력서를 찾을 수 없습니다."), RESUME_IN_USE(HttpStatus.CONFLICT, "사용 중인 이력서입니다."), + RESUME_INVALID_URL(HttpStatus.BAD_REQUEST, "등록할 수 없는 URL 입니다."), + RESUME_URL_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 URL 입니다."), COVER_LETTER_EMPTY(HttpStatus.BAD_REQUEST, "답변이 입력된 문항이 최소 1개 필요합니다."), COVER_LETTER_NOT_FOUND(HttpStatus.NOT_FOUND, "자소서를 찾을 수 없습니다."), diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java index cd2a1554..ab817c26 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RabbitMqConfig.java @@ -77,6 +77,11 @@ public Queue aiAnalyzeRepositoryQueue() { return workQueue(properties.queues().names().aiAnalyzeRepository()); } + @Bean + public Queue aiAnalyzeWebQueue() { + return workQueue(properties.queues().names().aiAnalyzeWeb()); + } + @Bean public Queue aiAnalyzeCoverLetterQueue() { return workQueue(properties.queues().names().aiAnalyzeCoverLetter()); @@ -140,6 +145,7 @@ public Declarables rabbitDeclarables( DirectExchange deadLetterExchange, Queue aiAnalyzeResumeQueue, Queue aiAnalyzeRepositoryQueue, + Queue aiAnalyzeWebQueue, Queue aiAnalyzeCoverLetterQueue, Queue aiGenerateQuestionsQueue, Queue aiGenerateFollowupQueue, @@ -154,6 +160,7 @@ public Declarables rabbitDeclarables( ) { Queue dlqAiAnalyzeResume = dlq(properties.queues().names().aiAnalyzeResume()); Queue dlqAiAnalyzeRepository = dlq(properties.queues().names().aiAnalyzeRepository()); + Queue dlqAiAnalyzeWeb = dlq(properties.queues().names().aiAnalyzeWeb()); Queue dlqAiAnalyzeCoverLetter = dlq(properties.queues().names().aiAnalyzeCoverLetter()); Queue dlqAiGenerateQuestions = dlq(properties.queues().names().aiGenerateQuestions()); Queue dlqAiGenerateFollowup = dlq(properties.queues().names().aiGenerateFollowup()); @@ -173,6 +180,7 @@ public Declarables rabbitDeclarables( deadLetterExchange, aiAnalyzeResumeQueue, aiAnalyzeRepositoryQueue, + aiAnalyzeWebQueue, aiAnalyzeCoverLetterQueue, aiGenerateQuestionsQueue, aiGenerateFollowupQueue, @@ -186,6 +194,7 @@ public Declarables rabbitDeclarables( coreCallbackTtsQueue, dlqAiAnalyzeResume, dlqAiAnalyzeRepository, + dlqAiAnalyzeWeb, dlqAiAnalyzeCoverLetter, dlqAiGenerateQuestions, dlqAiGenerateFollowup, @@ -199,6 +208,7 @@ public Declarables rabbitDeclarables( dlqCoreCallbackTts, BindingBuilder.bind(aiAnalyzeResumeQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeResume()), BindingBuilder.bind(aiAnalyzeRepositoryQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeRepository()), + BindingBuilder.bind(aiAnalyzeWebQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeWeb()), BindingBuilder.bind(aiAnalyzeCoverLetterQueue).to(coreToAiExchange).with(properties.routingKeys().analyzeCoverLetter()), BindingBuilder.bind(aiGenerateQuestionsQueue).to(coreToAiExchange).with(properties.routingKeys().generateQuestions()), BindingBuilder.bind(aiGenerateFollowupQueue).to(coreToAiExchange).with(properties.routingKeys().generateFollowup()), @@ -212,6 +222,7 @@ public Declarables rabbitDeclarables( BindingBuilder.bind(coreCallbackTtsQueue).to(aiToCoreExchange).with(properties.routingKeys().callbackTts()), BindingBuilder.bind(dlqAiAnalyzeResume).to(deadLetterExchange).with(dlqAiAnalyzeResume.getName()), BindingBuilder.bind(dlqAiAnalyzeRepository).to(deadLetterExchange).with(dlqAiAnalyzeRepository.getName()), + BindingBuilder.bind(dlqAiAnalyzeWeb).to(deadLetterExchange).with(dlqAiAnalyzeWeb.getName()), BindingBuilder.bind(dlqAiAnalyzeCoverLetter).to(deadLetterExchange).with(dlqAiAnalyzeCoverLetter.getName()), BindingBuilder.bind(dlqAiGenerateQuestions).to(deadLetterExchange).with(dlqAiGenerateQuestions.getName()), BindingBuilder.bind(dlqAiGenerateFollowup).to(deadLetterExchange).with(dlqAiGenerateFollowup.getName()), diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java index 80a175df..835579b8 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisRequestService.java @@ -10,6 +10,7 @@ import com.stackup.stackup.document.application.dto.AnalyzeCoverLetterPayload; import com.stackup.stackup.document.application.dto.AnalyzeRepositoryPayload; import com.stackup.stackup.document.application.dto.AnalyzeResumePayload; +import com.stackup.stackup.document.application.dto.AnalyzeWebPayload; import com.stackup.stackup.document.domain.AnalyzedDocument; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; import com.stackup.stackup.github.domain.GithubRepository; @@ -59,6 +60,28 @@ public AnalysisHandle requestResumeAnalysis(Long userId, Long resumeId) { return new AnalysisHandle(doc.getId(), resume.getId(), null); } + // 웹 이력서(URL) 분석. PDF 와 같은 Resume 행·AnalyzedDocument 를 쓰고 payload 만 URL 기반이다. + @Transactional + public AnalysisHandle requestWebResumeAnalysis(Long userId, Long resumeId) { + Resume resume = resumeRepository.findById(resumeId) + .orElseThrow(() -> new IllegalArgumentException("resume not found: " + resumeId)); + if (!resume.getUser().getId().equals(userId)) { + throw new IllegalArgumentException("resume does not belong to user"); + } + if (resume.getSourceUrl() == null || resume.getSourceUrl().isBlank()) { + throw new IllegalArgumentException("web resume has no source url: " + resumeId); + } + AnalyzedDocument doc = analyzedDocumentRepository.save(AnalyzedDocument.forResume(resume)); + resume.markAnalyzing(); + + events.publishEvent(new WebResumeAnalysisRequestedEvent( + userId, + doc.getId(), + new AnalyzeWebPayload(resume.getId(), resume.getSourceUrl(), doc.getId()) + )); + return new AnalysisHandle(doc.getId(), resume.getId(), null); + } + @Transactional public AnalysisHandle requestRepositoryAnalysis(Long userId, Long repositoryId) { GithubRepository repo = githubRepositoryRepository.findById(repositoryId) @@ -139,6 +162,16 @@ public void onResumeAnalysisRequested(ResumeAnalysisRequestedEvent event) { ); } + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onWebResumeAnalysisRequested(WebResumeAnalysisRequestedEvent event) { + publisher.publishToAi( + properties.routingKeys().analyzeWeb(), + event.payload(), + new MessageContext(event.userId(), null, event.documentId(), null) + ); + } + @Transactional(propagation = Propagation.NOT_SUPPORTED) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onRepositoryAnalysisRequested(RepositoryAnalysisRequestedEvent event) { @@ -165,6 +198,9 @@ public record AnalysisHandle(Long analyzedDocumentId, Long resumeId, Long reposi record ResumeAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeResumePayload payload) { } + record WebResumeAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeWebPayload payload) { + } + record RepositoryAnalysisRequestedEvent(Long userId, Long documentId, AnalyzeRepositoryPayload payload) { } diff --git a/backend/src/main/java/com/stackup/stackup/document/application/WebResumeAnalysisEventListener.java b/backend/src/main/java/com/stackup/stackup/document/application/WebResumeAnalysisEventListener.java new file mode 100644 index 00000000..31389975 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/WebResumeAnalysisEventListener.java @@ -0,0 +1,20 @@ +package com.stackup.stackup.document.application; + +import com.stackup.stackup.resume.application.event.WebResumeRegisteredEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +// resume 도메인이 발행한 WebResumeRegisteredEvent 를 받아 웹 분석 트리거. +// ResumeAnalysisEventListener 와 같은 패턴 — document → resume 단방향만 유지. +@Component +@RequiredArgsConstructor +public class WebResumeAnalysisEventListener { + + private final AnalysisRequestService analysisRequestService; + + @EventListener + public void on(WebResumeRegisteredEvent event) { + analysisRequestService.requestWebResumeAnalysis(event.userId(), event.resumeId()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeWebPayload.java b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeWebPayload.java new file mode 100644 index 00000000..5f81a744 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/document/application/dto/AnalyzeWebPayload.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.document.application.dto; + +// analyze.web envelope payload (Core → AI). AI 가 URL 본문을 추출 → 이력서와 동일 분석 체인으로 처리. +// docs/messaging.md §5.3. 콜백은 callback.analysis (targetType=WEB, targetId=resumeId). +public record AnalyzeWebPayload( + Long resumeId, + String url, + Long analyzedDocumentId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java index 3c112e78..ff7b6cc8 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java +++ b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java @@ -7,11 +7,13 @@ import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; import com.stackup.stackup.resume.application.event.ResumeDeletedEvent; import com.stackup.stackup.resume.application.event.ResumeUploadedEvent; +import com.stackup.stackup.resume.application.event.WebResumeRegisteredEvent; import com.stackup.stackup.resume.domain.Resume; import com.stackup.stackup.resume.domain.ResumeFileType; import com.stackup.stackup.resume.domain.ResumeRepository; import com.stackup.stackup.user.domain.User; import com.stackup.stackup.user.domain.UserRepository; +import java.net.URI; import java.util.List; import java.util.Locale; import java.util.UUID; @@ -32,6 +34,7 @@ public class ResumeService { private final ResumeRepository resumeRepository; private final UserRepository userRepository; private final ObjectStorageClient storage; + private final WebResumeUrlValidator urlValidator; private final ApplicationEventPublisher events; @Transactional @@ -53,6 +56,40 @@ public ResumeResult upload(Long userId, ResumeUploadCommand command) { return ResumeResult.of(resume); } + /** + * 웹 이력서(URL) 등록 — 포트폴리오·블로그·노션 링크. S3 업로드 없이 URL 만 저장하고 + * 본문 추출·분석은 AI 서버가 한다(analyze.web). docs/messaging.md §5.3. + */ + @Transactional + public ResumeResult registerWeb(Long userId, String rawUrl) { + URI url = urlValidator.validate(rawUrl); + String normalized = url.toString(); + + User user = userRepository.findByIdAndDeletedFalse(userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.USER_NOT_FOUND)); + + // 같은 URL 을 다시 등록하면 임베딩이 중복돼 질문이 한쪽으로 쏠린다. + if (resumeRepository.existsByUser_IdAndSourceUrlAndDeletedFalse(userId, normalized)) { + throw new DomainException(ApiErrorCode.RESUME_URL_DUPLICATE); + } + + Resume resume = resumeRepository.save( + Resume.createWeb(user, displayNameOf(url), normalized) + ); + + // document 도메인 listener 가 AnalyzedDocument(PROCESSING) 생성 + AFTER_COMMIT 으로 analyze.web 발행. + events.publishEvent(new WebResumeRegisteredEvent(userId, resume.getId())); + return ResumeResult.of(resume); + } + + // 목록에 보여줄 이름. original_filename 은 NOT NULL 이라 URL 에서 사람이 읽을 만한 값을 만든다. + private String displayNameOf(URI url) { + String host = url.getHost(); + String path = url.getPath() == null ? "" : url.getPath(); + String name = path.isBlank() || path.equals("/") ? host : host + path; + return name.length() > 500 ? name.substring(0, 500) : name; + } + public List list(Long userId) { return resumeRepository.findByUser_IdAndDeletedFalse(userId).stream() .map(ResumeResult::of) diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/WebResumeUrlValidator.java b/backend/src/main/java/com/stackup/stackup/resume/application/WebResumeUrlValidator.java new file mode 100644 index 00000000..44cde2ab --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/WebResumeUrlValidator.java @@ -0,0 +1,123 @@ +package com.stackup.stackup.resume.application; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Locale; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 사용자가 준 URL 을 AI 서버가 그대로 fetch 하므로 SSRF 검증이 필수다. + * + *

AI 서버는 docker 네트워크 안에서 Core·PostgreSQL·RabbitMQ·MinIO 에 닿을 수 있고, 배포 호스트에서는 + * 클라우드 메타데이터 엔드포인트(169.254.169.254)에도 닿는다. 검증 없이 넘기면 사용자가 + * {@code http://minio:9000/...} 이나 {@code http://169.254.169.254/latest/meta-data/} 를 "포트폴리오"로 + * 등록해 내부 응답을 요약문으로 돌려받을 수 있다. + * + *

여기서 호스트를 resolve 해 사설/루프백 대역을 막지만, DNS rebinding 과 리다이렉트로 우회할 수 있다. + * 실제 소켓을 여는 AI 서버(WebSourceExtractor)에도 같은 검사가 있어야 완결된다 — 이 클래스는 첫 번째 관문이다. + */ +@Component +public class WebResumeUrlValidator { + + private static final Logger log = LoggerFactory.getLogger(WebResumeUrlValidator.class); + + // resumes.source_url 컬럼 길이. + private static final int MAX_URL_LENGTH = 2000; + private static final Set ALLOWED_SCHEMES = Set.of("http", "https"); + + /** 호스트 → 주소 해석. 테스트에서 실제 DNS 를 타지 않도록 분리한다. */ + public interface HostResolver { + InetAddress[] resolve(String host) throws UnknownHostException; + } + + private final HostResolver resolver; + + // Spring 은 다른 생성자에 @Autowired 가 없으면 기본 생성자를 쓴다. + public WebResumeUrlValidator() { + this(InetAddress::getAllByName); + } + + WebResumeUrlValidator(HostResolver resolver) { + this.resolver = resolver; + } + + /** 정규화된 URI 를 반환한다. 부적합하면 {@link DomainException}(RESUME_INVALID_URL). */ + public URI validate(String raw) { + if (raw == null || raw.isBlank()) { + throw reject("URL 을 입력해 주세요."); + } + String trimmed = raw.trim(); + if (trimmed.length() > MAX_URL_LENGTH) { + throw reject("URL 이 너무 깁니다. (최대 %d자)".formatted(MAX_URL_LENGTH)); + } + + URI uri; + try { + uri = new URI(trimmed); + } catch (URISyntaxException e) { + throw reject("URL 형식이 올바르지 않습니다."); + } + if (!uri.isAbsolute()) { + throw reject("http:// 또는 https:// 로 시작하는 전체 주소를 입력해 주세요."); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!ALLOWED_SCHEMES.contains(scheme)) { + throw reject("http, https 주소만 등록할 수 있습니다."); + } + // user:pass@host 는 리다이렉트/파서 차이를 이용한 호스트 위장에 쓰인다. + if (uri.getUserInfo() != null) { + throw reject("사용자 정보가 포함된 URL 은 등록할 수 없습니다."); + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw reject("URL 에서 호스트를 찾을 수 없습니다."); + } + requirePublicHost(host); + return uri; + } + + private void requirePublicHost(String host) { + InetAddress[] addresses; + try { + addresses = resolver.resolve(host); + } catch (UnknownHostException e) { + throw reject("주소를 찾을 수 없는 호스트입니다: %s".formatted(host)); + } + for (InetAddress address : addresses) { + if (isBlocked(address)) { + // 어떤 내부 주소로 해석됐는지는 응답에 노출하지 않는다(내부 토폴로지 추측 방지). + log.warn("web resume URL rejected — non-public address. host={}, resolved={}", + host, address.getHostAddress()); + throw reject("내부 네트워크 주소는 등록할 수 없습니다."); + } + } + } + + // 루프백·사설(RFC1918)·링크로컬(169.254/16, 클라우드 메타데이터 포함)·멀티캐스트· + // 와일드카드(0.0.0.0)·IPv6 unique-local 을 모두 막는다. + private boolean isBlocked(InetAddress address) { + return address.isLoopbackAddress() + || address.isAnyLocalAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isMulticastAddress() + || isUniqueLocalIpv6(address); + } + + // fc00::/7 — isSiteLocalAddress() 가 IPv6 에서는 deprecated 인 fec0::/10 만 보므로 별도 확인. + private boolean isUniqueLocalIpv6(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; + } + + private DomainException reject(String message) { + return new DomainException(ApiErrorCode.RESUME_INVALID_URL, message); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java index aa745cbb..196d49c7 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java +++ b/backend/src/main/java/com/stackup/stackup/resume/application/dto/ResumeResult.java @@ -11,6 +11,8 @@ public record ResumeResult( String filePath, ResumeFileType fileType, Long fileSize, + // WEB 타입의 원문 URL. PDF 는 null. + String sourceUrl, ResumeStatus status, Instant createdAt, Instant updatedAt @@ -22,6 +24,7 @@ public static ResumeResult of(Resume resume) { resume.getFilePath(), resume.getFileType(), resume.getFileSize(), + resume.getSourceUrl(), resume.getStatus(), resume.getCreatedAt(), resume.getUpdatedAt() diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/event/WebResumeRegisteredEvent.java b/backend/src/main/java/com/stackup/stackup/resume/application/event/WebResumeRegisteredEvent.java new file mode 100644 index 00000000..74c69545 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/application/event/WebResumeRegisteredEvent.java @@ -0,0 +1,9 @@ +package com.stackup.stackup.resume.application.event; + +// 웹 이력서(URL) 등록 직후 발행. document 도메인이 listener 로 받아 analyze.web 분석을 트리거한다. +// PDF 업로드의 ResumeUploadedEvent 와 같은 역할 — resume → document 직접 의존을 피하기 위한 매개체. +public record WebResumeRegisteredEvent( + Long userId, + Long resumeId +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java index 33df1d51..5bd05933 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java @@ -40,7 +40,8 @@ public class Resume extends BaseSoftDeleteEntity { @Column(name = "original_filename", nullable = false, length = 500) private String originalFilename; - @Column(name = "file_path", nullable = false, length = 1000) + // PDF 는 S3 키, WEB 은 null. (DB CHECK chk_resumes_locator_by_type 로 타입별 필수 강제) + @Column(name = "file_path", length = 1000) private String filePath; @Column(name = "file_type", nullable = false, length = 20) @@ -50,23 +51,44 @@ public class Resume extends BaseSoftDeleteEntity { @Column(name = "file_size") private Long fileSize; + // WEB 전용 — 분석 대상 원문 URL. PDF 는 null. + @Column(name = "source_url", length = 2000) + private String sourceUrl; + @Column(nullable = false, length = 20) @Enumerated(EnumType.STRING) private ResumeStatus status = ResumeStatus.PENDING; - private Resume(User user, String originalFilename, String filePath, ResumeFileType fileType, Long fileSize) { + private Resume(User user, String originalFilename, String filePath, ResumeFileType fileType, + Long fileSize, String sourceUrl) { this.user = user; this.originalFilename = originalFilename; this.filePath = filePath; this.fileType = fileType; this.fileSize = fileSize; + this.sourceUrl = sourceUrl; } public static Resume create(User user, String originalFilename, String filePath, ResumeFileType fileType, Long fileSize) { if (user == null) { throw new IllegalArgumentException("user must not be null"); } - return new Resume(user, originalFilename, filePath, fileType, fileSize); + return new Resume(user, originalFilename, filePath, fileType, fileSize, null); + } + + // 웹 이력서 — S3 업로드 없이 URL 만 보관하고, 본문 추출은 AI 서버가 한다(analyze.web). + public static Resume createWeb(User user, String displayName, String sourceUrl) { + if (user == null) { + throw new IllegalArgumentException("user must not be null"); + } + if (sourceUrl == null || sourceUrl.isBlank()) { + throw new IllegalArgumentException("sourceUrl must not be blank"); + } + return new Resume(user, displayName, null, ResumeFileType.WEB, null, sourceUrl); + } + + public boolean isWeb() { + return this.fileType == ResumeFileType.WEB; } public void markAnalyzing() { diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java index bc6fa787..742273f3 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeFileType.java @@ -1,5 +1,7 @@ package com.stackup.stackup.resume.domain; public enum ResumeFileType { - PDF + PDF, + // 웹 이력서(포트폴리오·블로그·노션 URL). S3 오브젝트 없이 source_url 만 갖는다. + WEB } diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java index 321a2d2e..43cc6730 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java +++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java @@ -9,4 +9,6 @@ public interface ResumeRepository extends JpaRepository { List findByUser_IdAndDeletedFalse(Long userId); Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId); + + boolean existsByUser_IdAndSourceUrlAndDeletedFalse(Long userId, String sourceUrl); } diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java index 2598281a..1c150573 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java @@ -6,10 +6,12 @@ import com.stackup.stackup.resume.application.ResumeService; import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; import com.stackup.stackup.resume.presentation.dto.ResumeResponse; +import com.stackup.stackup.resume.presentation.dto.WebResumeCreateRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -20,6 +22,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -66,6 +69,28 @@ public ResumeResponse upload( } } + @Operation( + operationId = "registerWebResume", + summary = "웹 이력서(URL) 등록 + 분석 트리거 (US-09)", + description = "포트폴리오·블로그·노션 등 공개 URL 을 이력서 자료로 등록한다. 파일 업로드 없이 " + + "URL 만 저장하고, 본문 추출·요약·임베딩은 AI 서버가 수행(analyze.web). 결과는 " + + "/realtime/stream/me (DOC_STATE) 로 통지된다. http·https 공개 주소만 허용(내부망 차단)." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "등록 + 분석 트리거 성공"), + @ApiResponse(responseCode = "400", description = "URL 형식 오류 / 비-http(s) / 내부망 주소"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "409", description = "이미 등록된 URL") + }) + @PostMapping(value = "/web", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResumeResponse registerWeb( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody WebResumeCreateRequest request + ) { + return ResumeResponse.from(resumeService.registerWeb(principal.userId(), request.url())); + } + @Operation(operationId = "listResumes", summary = "내 이력서 목록") @ApiResponses({ @ApiResponse(responseCode = "200", description = "사용자 소유 이력서 목록"), diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java index b272d2b4..6d3a45db 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/ResumeResponse.java @@ -11,6 +11,8 @@ public record ResumeResponse( String filePath, ResumeFileType fileType, Long fileSize, + // WEB 타입의 원문 URL. PDF 는 null. + String sourceUrl, ResumeStatus status, Instant createdAt, Instant updatedAt @@ -22,6 +24,7 @@ public static ResumeResponse from(ResumeResult result) { result.filePath(), result.fileType(), result.fileSize(), + result.sourceUrl(), result.status(), result.createdAt(), result.updatedAt() diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/WebResumeCreateRequest.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/WebResumeCreateRequest.java new file mode 100644 index 00000000..05f9efa8 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/dto/WebResumeCreateRequest.java @@ -0,0 +1,10 @@ +package com.stackup.stackup.resume.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +// 웹 이력서(URL) 등록 요청. 스킴·호스트 검증은 WebResumeUrlValidator(SSRF 가드)가 담당한다. +public record WebResumeCreateRequest( + @NotBlank @Size(max = 2000) String url +) { +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 33bf08e7..a792ab8d 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -105,6 +105,7 @@ app: names: ai-analyze-resume: ai.analyze.resume ai-analyze-repository: ai.analyze.repository + ai-analyze-web: ai.analyze.web ai-analyze-cover-letter: ai.analyze.cover_letter ai-generate-questions: ai.generate.questions ai-generate-followup: ai.generate.followup @@ -119,6 +120,7 @@ app: routing-keys: analyze-resume: analyze.resume analyze-repository: analyze.repository + analyze-web: analyze.web analyze-cover-letter: analyze.cover_letter generate-questions: generate.questions generate-followup: generate.followup diff --git a/backend/src/main/resources/db/migration/V24__add_web_resume.sql b/backend/src/main/resources/db/migration/V24__add_web_resume.sql new file mode 100644 index 00000000..482ab56e --- /dev/null +++ b/backend/src/main/resources/db/migration/V24__add_web_resume.sql @@ -0,0 +1,19 @@ +-- US-09 웹 이력서(URL) — 포트폴리오·블로그·노션 링크를 이력서 자료로 등록한다. +-- docs/messaging.md §5.3 대로 resume 도메인을 재사용하므로(analyze.web 의 payload 가 resumeId) +-- 새 테이블을 만들지 않고 resumes 에 WEB 타입을 추가한다. + +-- WEB 은 S3 오브젝트가 없다(원문은 URL). file_path 를 nullable 로 전환. +ALTER TABLE resumes ALTER COLUMN file_path DROP NOT NULL; + +ALTER TABLE resumes ADD COLUMN source_url VARCHAR(2000); + +ALTER TABLE resumes DROP CONSTRAINT chk_resumes_file_type; +ALTER TABLE resumes ADD CONSTRAINT chk_resumes_file_type + CHECK (file_type IN ('PDF', 'WEB')); + +-- 타입별 필수 컬럼을 DB 에서 강제 — PDF 는 S3 키, WEB 은 URL 이 반드시 있어야 한다. +ALTER TABLE resumes ADD CONSTRAINT chk_resumes_locator_by_type + CHECK ( + (file_type = 'PDF' AND file_path IS NOT NULL) + OR (file_type = 'WEB' AND source_url IS NOT NULL) + ); diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 65ff3d17..69e1fa4d 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -90,6 +90,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.Queues.Names( "ai.analyze.resume", "ai.analyze.repository", + "ai.analyze.web", "ai.analyze.cover_letter", "ai.generate.questions", "ai.generate.followup", @@ -106,6 +107,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", + "analyze.web", "analyze.cover_letter", "generate.questions", "generate.followup", diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java index bd2ed284..fc0790d0 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java @@ -59,6 +59,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.Queues.Names( "ai.analyze.resume", "ai.analyze.repository", + "ai.analyze.web", "ai.analyze.cover_letter", "ai.generate.questions", "ai.generate.followup", @@ -75,6 +76,7 @@ private RabbitMqProperties rabbitMqProperties() { new RabbitMqProperties.RoutingKeyProperties( "analyze.resume", "analyze.repository", + "analyze.web", "analyze.cover_letter", "generate.questions", "generate.followup", diff --git a/backend/src/test/java/com/stackup/stackup/resume/application/WebResumeRegisterTest.java b/backend/src/test/java/com/stackup/stackup/resume/application/WebResumeRegisterTest.java new file mode 100644 index 00000000..fa3e697a --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/application/WebResumeRegisterTest.java @@ -0,0 +1,135 @@ +package com.stackup.stackup.resume.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectStorageClient; +import com.stackup.stackup.resume.application.dto.ResumeResult; +import com.stackup.stackup.resume.application.event.WebResumeRegisteredEvent; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.resume.domain.ResumeStatus; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +// 웹 이력서(URL) 등록 — US-09. S3 업로드 없이 URL 만 저장하고 analyze.web 트리거 이벤트를 낸다. +@ExtendWith(MockitoExtension.class) +class WebResumeRegisterTest { + + @Mock ResumeRepository resumeRepository; + @Mock UserRepository userRepository; + @Mock ObjectStorageClient storage; + @Mock ApplicationEventPublisher events; + + // DNS 는 fake — 단위 테스트가 네트워크에 의존하지 않게 한다. + private static final WebResumeUrlValidator VALIDATOR = new WebResumeUrlValidator(host -> { + if (host.endsWith("example.com")) { + return new java.net.InetAddress[] {java.net.InetAddress.getByName("93.184.216.34")}; + } + return new java.net.InetAddress[] {java.net.InetAddress.getByName(host)}; + }); + + private ResumeService service() { + return new ResumeService(resumeRepository, userRepository, storage, VALIDATOR, events); + } + + @Test + void registerWeb_savesUrlWithoutStorageAndPublishesEvent() { + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user())); + when(resumeRepository.existsByUser_IdAndSourceUrlAndDeletedFalse(1L, "https://example.com/portfolio")) + .thenReturn(false); + when(resumeRepository.save(any(Resume.class))).thenAnswer(inv -> { + Resume r = inv.getArgument(0); + ReflectionTestUtils.setField(r, "id", 42L); + return r; + }); + + ResumeResult result = service().registerWeb(1L, " https://example.com/portfolio "); + + assertThat(result.id()).isEqualTo(42L); + assertThat(result.fileType()).isEqualTo(ResumeFileType.WEB); + assertThat(result.sourceUrl()).isEqualTo("https://example.com/portfolio"); + assertThat(result.filePath()).isNull(); + assertThat(result.fileSize()).isNull(); + assertThat(result.status()).isEqualTo(ResumeStatus.PENDING); + // 목록 표시용 이름은 host + path 로 만든다(original_filename 이 NOT NULL). + assertThat(result.originalFilename()).isEqualTo("example.com/portfolio"); + + // URL 은 AI 가 직접 fetch 하므로 Core 는 S3 에 아무것도 올리지 않는다. + verify(storage, never()).put(any(), any(), anyLong(), any()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WebResumeRegisteredEvent.class); + verify(events).publishEvent(captor.capture()); + assertThat(captor.getValue().userId()).isEqualTo(1L); + assertThat(captor.getValue().resumeId()).isEqualTo(42L); + } + + @Test + void registerWeb_usesHostOnlyWhenPathIsEmpty() { + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user())); + when(resumeRepository.save(any(Resume.class))).thenAnswer(inv -> inv.getArgument(0)); + + ResumeResult result = service().registerWeb(1L, "https://blog.example.com/"); + + assertThat(result.originalFilename()).isEqualTo("blog.example.com"); + } + + // 같은 URL 을 두 번 등록하면 임베딩이 중복돼 질문이 한쪽으로 쏠린다. + @Test + void registerWeb_rejectsDuplicateUrl() { + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(user())); + when(resumeRepository.existsByUser_IdAndSourceUrlAndDeletedFalse(1L, "https://example.com/me")) + .thenReturn(true); + + assertThatThrownBy(() -> service().registerWeb(1L, "https://example.com/me")) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.RESUME_URL_DUPLICATE)); + + verify(resumeRepository, never()).save(any()); + verify(events, never()).publishEvent(any()); + } + + // SSRF 차단은 사용자 조회보다 먼저 — 내부망 주소는 DB 를 건드리지도 않는다. + @Test + void registerWeb_rejectsInternalAddressBeforeTouchingDb() { + assertThatThrownBy(() -> service().registerWeb(1L, "http://127.0.0.1:8080/api/internal/documents/1")) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.RESUME_INVALID_URL)); + + verify(userRepository, never()).findByIdAndDeletedFalse(anyLong()); + verify(resumeRepository, never()).save(any()); + verify(events, never()).publishEvent(any()); + } + + @Test + void registerWeb_rejectsWhenUserNotFound() { + when(userRepository.findByIdAndDeletedFalse(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().registerWeb(1L, "https://example.com/me")) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.USER_NOT_FOUND)); + } + + private User user() { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + return user; + } +} diff --git a/backend/src/test/java/com/stackup/stackup/resume/application/WebResumeUrlValidatorTest.java b/backend/src/test/java/com/stackup/stackup/resume/application/WebResumeUrlValidatorTest.java new file mode 100644 index 00000000..6949168a --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/resume/application/WebResumeUrlValidatorTest.java @@ -0,0 +1,118 @@ +package com.stackup.stackup.resume.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stackup.stackup.common.exception.ApiErrorCode; +import com.stackup.stackup.common.exception.DomainException; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * SSRF 가드. AI 서버가 이 URL 을 그대로 fetch 하므로 내부망 접근이 뚫리면 안 된다. + * + *

DNS 는 주입한 fake 로 해석한다 — 실제 조회를 타면 테스트가 네트워크에 의존해 CI 에서 흔들린다. + * IP 리터럴은 fake 없이도 파싱되므로 대역 판정은 실제 {@link InetAddress} 로 검증된다. + */ +class WebResumeUrlValidatorTest { + + // 도메인 → 해석 결과. 없는 이름은 UnknownHostException. + private static final Map DNS = Map.of( + "example.com", "93.184.216.34", + "blog.example.com", "93.184.216.34", + "internal.example.com", "10.1.2.3" // 공개 도메인이 사설 IP 로 해석되는 경우 + ); + + private final WebResumeUrlValidator validator = new WebResumeUrlValidator(host -> { + String mapped = DNS.get(host); + if (mapped != null) { + return new InetAddress[] {InetAddress.getByName(mapped)}; + } + // IP 리터럴은 그대로 파싱(DNS 조회 없음), 그 외 미등록 이름은 해석 실패. + if (host.matches("[0-9.]+") || host.startsWith("[") || host.contains(":")) { + return new InetAddress[] {InetAddress.getByName(host)}; + } + if (host.equals("localhost")) { + return new InetAddress[] {InetAddress.getByName("127.0.0.1")}; + } + throw new UnknownHostException(host); + }); + + @Test + void acceptsPublicHttpsUrl() { + URI uri = validator.validate("https://example.com/portfolio"); + + assertThat(uri.getHost()).isEqualTo("example.com"); + assertThat(uri.toString()).isEqualTo("https://example.com/portfolio"); + } + + @Test + void trimsWhitespace() { + assertThat(validator.validate(" https://example.com/me ").toString()) + .isEqualTo("https://example.com/me"); + } + + // 루프백·사설·링크로컬(클라우드 메타데이터)·와일드카드 — AI 컨테이너가 닿을 수 있는 대역 전부. + @ParameterizedTest + @ValueSource(strings = { + "http://127.0.0.1:8080/api/internal/documents/1", + "http://localhost:9000/stackup", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.5/", + "http://172.16.0.1/", + "http://192.168.1.1/", + "http://0.0.0.0/", + }) + void rejectsNonPublicAddresses(String url) { + assertRejected(url); + } + + // 공개 도메인이라도 해석 결과가 사설이면 막는다 — 이름만 보고 판단하면 뚫린다. + @Test + void rejectsPublicHostnameResolvingToPrivateAddress() { + assertRejected("https://internal.example.com/"); + } + + @ParameterizedTest + @ValueSource(strings = { + "file:///etc/passwd", + "gopher://example.com/", + "ftp://example.com/resume.pdf", + "javascript:alert(1)", + "//example.com/no-scheme", + "example.com", + "not a url at all", + }) + void rejectsUnsupportedSchemes(String url) { + assertRejected(url); + } + + // user:pass@ 는 파서 차이를 이용한 호스트 위장에 쓰인다. + @Test + void rejectsUserInfo() { + assertRejected("https://evil.example.com@example.com/"); + } + + @Test + void rejectsBlankAndOverlongUrl() { + assertRejected(null); + assertRejected(" "); + assertRejected("https://example.com/" + "a".repeat(2000)); + } + + @Test + void rejectsUnresolvableHost() { + assertRejected("https://this-host-should-not-resolve.invalid/"); + } + + private void assertRejected(String url) { + assertThatThrownBy(() -> validator.validate(url)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.RESUME_INVALID_URL)); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/MessageSequenceAssignmentTest.java b/backend/src/test/java/com/stackup/stackup/session/application/MessageSequenceAssignmentTest.java index 62a79c77..1fc39ae7 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/MessageSequenceAssignmentTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/MessageSequenceAssignmentTest.java @@ -86,8 +86,8 @@ private InterviewSession sessionFixture() { private RabbitMqProperties.RoutingKeyProperties routingKeys() { return new RabbitMqProperties.RoutingKeyProperties( - "x", "x", "x", "x", "generate.followup", "x", "x", "x", - "x", "x", "x", "x", "x", "x", "x", "x" + "x", "x", "x", "x", "x", "generate.followup", "x", "x", + "x", "x", "x", "x", "x", "x", "x", "x", "x" ); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index 16f6ac1f..fb6301b3 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -135,7 +135,7 @@ private InterviewSession sessionFixture(Long id) { private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { return new RabbitMqProperties.RoutingKeyProperties( - "analyze.resume", "analyze.repository", "analyze.cover_letter", + "analyze.resume", "analyze.repository", "analyze.web", "analyze.cover_letter", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", "generate.tts", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java index 7a819b06..87917042 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionQuestionsRequesterTest.java @@ -111,8 +111,8 @@ void generalCountThree_requestsPoolOfTwo() { // record 라 mock 이 안 된다 — 실제 인스턴스를 만든다. 이 테스트가 보는 건 generateQuestions 뿐. private RabbitMqProperties.RoutingKeyProperties routingKeys() { return new RabbitMqProperties.RoutingKeyProperties( - "x", "x", "x", "generate.questions", "x", "x", "x", "x", - "x", "x", "x", "x", "x", "x", "x", "x" + "x", "x", "x", "x", "generate.questions", "x", "x", "x", + "x", "x", "x", "x", "x", "x", "x", "x", "x" ); } } diff --git a/docs/database.md b/docs/database.md index 9bbfed1e..5ed0e431 100644 --- a/docs/database.md +++ b/docs/database.md @@ -25,7 +25,7 @@ interview_sessions → session_feedbacks | 2 | `refresh_tokens` | JWT refresh token (해시 저장) | | 3 | `user_consents` | 개인정보처리동의 이력 | | 4 | `repositories` | 면접 분석용 GitHub 레포 메타 | -| 5 | `resumes` | 이력서 메타 (실 파일은 S3) | +| 5 | `resumes` | 이력서 메타 — PDF(실 파일은 S3) + 웹 URL 자료(`file_type='WEB'`, `source_url`) | | 5-1 | `cover_letters` | 자소서(공채) 문항별 텍스트 (`items` JSONB: `[{question,answer}]`). V20 | | 6 | `analyzed_documents` | AI 분석 결과 메타 + S3 경로. 다형성 FK `resume_id`/`repository_id`/`cover_letter_id`(V20) 중 정확히 하나 | | 7 | `interview_sessions` | 면접 세션 설정·상태·히스토리 | @@ -112,19 +112,25 @@ CREATE TABLE repositories ( UNIQUE (user_id, github_repo_id) ); --- 5. resumes +-- 5. resumes (PDF 업로드 + 웹 URL 자료를 함께 담는다 — V24) CREATE TABLE resumes ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), - original_filename VARCHAR(500) NOT NULL, - file_path VARCHAR(1000) NOT NULL, -- S3 key only - file_type VARCHAR(20) NOT NULL CHECK (file_type IN ('PDF')), - file_size BIGINT, + original_filename VARCHAR(500) NOT NULL, -- WEB 은 host+path 를 표시명으로 + file_path VARCHAR(1000), -- S3 key only. WEB 은 NULL + file_type VARCHAR(20) NOT NULL CHECK (file_type IN ('PDF','WEB')), + file_size BIGINT, -- WEB 은 NULL + source_url VARCHAR(2000), -- WEB 전용 원문 URL. PDF 는 NULL status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','ANALYZING','ANALYZED','FAILED')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - is_deleted BOOLEAN NOT NULL DEFAULT FALSE + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + -- 타입별 필수 locator 를 DB 에서 강제 + CONSTRAINT chk_resumes_locator_by_type CHECK ( + (file_type = 'PDF' AND file_path IS NOT NULL) + OR (file_type = 'WEB' AND source_url IS NOT NULL) + ) ); -- 6. analyzed_documents diff --git a/frontend/src/features/resume/api/resume.ts b/frontend/src/features/resume/api/resume.ts index abf576bb..082a3115 100644 --- a/frontend/src/features/resume/api/resume.ts +++ b/frontend/src/features/resume/api/resume.ts @@ -18,6 +18,12 @@ export async function uploadResume(file: File): Promise { return response.data } +// 웹 이력서(URL) 등록. 파일 업로드 없이 URL 만 보내고 본문 추출은 서버(AI)가 한다. +export async function registerWebResume(url: string): Promise { + const response = await apiClient.post('/api/resumes/web', { url }) + return response.data +} + export async function deleteResume(id: number): Promise { await apiClient.delete(`/api/resumes/${id}`) } diff --git a/frontend/src/features/resume/index.ts b/frontend/src/features/resume/index.ts index 45a24fca..c5a61d94 100644 --- a/frontend/src/features/resume/index.ts +++ b/frontend/src/features/resume/index.ts @@ -1,8 +1,10 @@ export { ResumeUploader } from './ui/ResumeUploader' export { ResumeList } from './ui/ResumeList' +export { WebResumeForm } from './ui/WebResumeForm' export { useResumes, useUploadResume, + useRegisterWebResume, useDeleteResume, resumeKeys, } from './model/useResumes' diff --git a/frontend/src/features/resume/lib/format.ts b/frontend/src/features/resume/lib/format.ts index 55236e45..d330a419 100644 --- a/frontend/src/features/resume/lib/format.ts +++ b/frontend/src/features/resume/lib/format.ts @@ -1,4 +1,6 @@ -export function formatFileSize(bytes: number): string { +// WEB 타입 이력서는 파일이 없어 fileSize 가 null 이다. +export function formatFileSize(bytes: number | null | undefined): string { + if (bytes == null) return '' if (bytes < 1024) return `${bytes} B` const kb = bytes / 1024 if (kb < 1024) return `${kb.toFixed(0)} KB` diff --git a/frontend/src/features/resume/model/types.ts b/frontend/src/features/resume/model/types.ts index 0d6698e2..faaece3d 100644 --- a/frontend/src/features/resume/model/types.ts +++ b/frontend/src/features/resume/model/types.ts @@ -1,13 +1,16 @@ export type ResumeStatus = 'PENDING' | 'ANALYZING' | 'ANALYZED' | 'FAILED' -export type ResumeFileType = 'PDF' +// WEB = 포트폴리오·블로그·노션 등 URL 자료. S3 파일이 없어 filePath/fileSize 가 비고 +// 대신 sourceUrl 이 채워진다. +export type ResumeFileType = 'PDF' | 'WEB' export type Resume = { id: number originalFilename: string - filePath: string + filePath: string | null fileType: ResumeFileType - fileSize: number + fileSize: number | null + sourceUrl: string | null status: ResumeStatus createdAt: string updatedAt: string diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts index 8e32dffb..98627c09 100644 --- a/frontend/src/features/resume/model/useResumes.ts +++ b/frontend/src/features/resume/model/useResumes.ts @@ -1,7 +1,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { isApiError } from '@/shared/api' import { toast } from '@/shared/ui' -import { deleteResume, fetchResumes, uploadResume } from '../api/resume' +import { + deleteResume, + fetchResumes, + registerWebResume, + uploadResume, +} from '../api/resume' import type { Resume } from './types' const errMessage = (e: unknown, fallback: string) => @@ -29,6 +34,17 @@ export function useUploadResume() { }) } +export function useRegisterWebResume() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: registerWebResume, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: resumeKeys.all }) + toast.success('링크를 등록했어요. 분석이 곧 시작됩니다.') + }, + }) +} + export function useDeleteResume() { const queryClient = useQueryClient() return useMutation({ diff --git a/frontend/src/features/resume/ui/ResumeList.test.tsx b/frontend/src/features/resume/ui/ResumeList.test.tsx new file mode 100644 index 00000000..7369fbfd --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeList.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ResumeList } from './ResumeList' +import type { Resume } from '../model/types' + +const useResumes = vi.fn() +vi.mock('../model/useResumes', () => ({ + useResumes: () => useResumes(), + useDeleteResume: () => ({ mutate: vi.fn(), isPending: false }), +})) +vi.mock('@/shared/hooks', () => ({ useAnalysisProgress: () => null })) + +const base: Resume = { + id: 1, + originalFilename: 'resume.pdf', + filePath: 'resumes/raw/1/x.pdf', + fileType: 'PDF', + fileSize: 2048, + sourceUrl: null, + status: 'ANALYZED', + createdAt: '2026-08-01T00:00:00Z', + updatedAt: '2026-08-01T00:00:00Z', +} + +const webResume: Resume = { + ...base, + id: 2, + originalFilename: 'my-portfolio.dev/about', + filePath: null, + fileType: 'WEB', + fileSize: null, + sourceUrl: 'https://my-portfolio.dev/about', +} + +function renderWith(data: Resume[]) { + useResumes.mockReturnValue({ + data, + isPending: false, + isError: false, + refetch: vi.fn(), + }) + return render() +} + +describe('ResumeList', () => { + it('WEB 항목은 원문으로 가는 링크로 렌더한다', () => { + renderWith([webResume]) + + const link = screen.getByRole('link', { name: 'my-portfolio.dev/about' }) + expect(link).toHaveAttribute('href', 'https://my-portfolio.dev/about') + // 외부 링크 — 탭 탈취(reverse tabnabbing) 방지 속성이 붙어야 한다. + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) + expect(screen.getByText('웹 링크')).toBeInTheDocument() + }) + + it('PDF 항목은 링크가 아니라 파일 크기를 보여준다', () => { + renderWith([base]) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByText('2 KB')).toBeInTheDocument() + }) + + // fileSize 가 null 인 WEB 항목이 섞여도 목록이 깨지지 않아야 한다. + it('파일과 링크를 한 목록에 함께 렌더한다', () => { + renderWith([base, webResume]) + + expect(screen.getAllByRole('listitem')).toHaveLength(2) + expect(screen.getByLabelText('링크 삭제')).toBeInTheDocument() + expect(screen.getByLabelText('이력서 삭제')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx index d15d7270..d2186dd4 100644 --- a/frontend/src/features/resume/ui/ResumeList.tsx +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -25,8 +25,8 @@ export function ResumeList() { if (data.length === 0) { return ( ) } @@ -55,6 +55,7 @@ function ResumeCard({ onDelete: () => void }) { const meta = STATUS_META[resume.status] + const isWeb = resume.fileType === 'WEB' const [confirmOpen, setConfirmOpen] = useState(false) const progress = useAnalysisProgress('RESUME', resume.id) const showProgress = @@ -67,19 +68,31 @@ function ResumeCard({ aria-hidden className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-fg" > - + {isWeb ? : }

-

- {resume.originalFilename} -

+ {isWeb && resume.sourceUrl ? ( + + {resume.originalFilename} + + ) : ( +

+ {resume.originalFilename} +

+ )}
@@ -127,6 +144,25 @@ function FileIcon() { ) } +function LinkIcon() { + return ( + + + + + + ) +} + function TrashIcon() { return ( ({ + useRegisterWebResume: () => ({ mutate, isPending: false }), +})) + +beforeEach(() => mutate.mockClear()) + +describe('WebResumeForm', () => { + it('공개 URL을 등록하면 다듬은 값으로 요청한다', async () => { + render() + + await userEvent.type( + screen.getByLabelText('포트폴리오·블로그 링크'), + ' https://my-portfolio.dev/about ', + ) + await userEvent.click(screen.getByRole('button', { name: '링크 등록' })) + + expect(mutate).toHaveBeenCalledTimes(1) + expect(mutate.mock.calls[0][0]).toBe('https://my-portfolio.dev/about') + }) + + it('빈 입력은 서버에 보내지 않고 안내한다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '링크 등록' })) + + expect(mutate).not.toHaveBeenCalled() + expect(screen.getByRole('alert')).toHaveTextContent('URL을 입력해 주세요.') + }) + + // 서버가 최종 판정하지만 명백한 실수는 왕복 없이 잡는다. + it.each(['portfolio.dev', 'ftp://example.com/x'])( + 'http(s)가 아닌 %s 는 왕복 없이 거부한다', + async (value) => { + render() + + await userEvent.type(screen.getByLabelText('포트폴리오·블로그 링크'), value) + await userEvent.click(screen.getByRole('button', { name: '링크 등록' })) + + expect(mutate).not.toHaveBeenCalled() + expect(screen.getByRole('alert')).toBeInTheDocument() + }, + ) + + it('다시 입력하면 에러 메시지가 사라진다', async () => { + render() + const input = screen.getByLabelText('포트폴리오·블로그 링크') + + await userEvent.click(screen.getByRole('button', { name: '링크 등록' })) + expect(screen.getByRole('alert')).toBeInTheDocument() + + await userEvent.type(input, 'h') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(input).not.toHaveAttribute('aria-invalid') + }) +}) diff --git a/frontend/src/features/resume/ui/WebResumeForm.tsx b/frontend/src/features/resume/ui/WebResumeForm.tsx new file mode 100644 index 00000000..e0ce4945 --- /dev/null +++ b/frontend/src/features/resume/ui/WebResumeForm.tsx @@ -0,0 +1,88 @@ +import { useId, useState } from 'react' +import { isApiError } from '@/shared/api' +import { Button } from '@/shared/ui/Button' +import { useRegisterWebResume } from '../model/useResumes' + +// 서버(WebResumeUrlValidator)가 최종 판정하지만, 왕복 전에 명백한 실수는 여기서 잡는다. +function localReason(raw: string): string | null { + const value = raw.trim() + if (!value) return 'URL을 입력해 주세요.' + let parsed: URL + try { + parsed = new URL(value) + } catch { + return 'http:// 또는 https:// 로 시작하는 전체 주소를 입력해 주세요.' + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return 'http, https 주소만 등록할 수 있습니다.' + } + return null +} + +export function WebResumeForm() { + const inputId = useId() + const errorId = useId() + const [value, setValue] = useState('') + const [error, setError] = useState(null) + const register = useRegisterWebResume() + + const submit = () => { + const reason = localReason(value) + if (reason) { + setError(reason) + return + } + setError(null) + register.mutate(value.trim(), { + onSuccess: () => setValue(''), + onError: (e) => + setError(isApiError(e) ? e.message : '링크 등록에 실패했습니다.'), + }) + } + + return ( +
{ + e.preventDefault() + submit() + }} + > + +
+ { + setValue(e.target.value) + if (error) setError(null) + }} + className="min-w-0 flex-1 rounded-lg border border-border bg-surface-raised px-3 py-2.5 text-body text-fg placeholder:text-fg-subtle focus-visible:border-primary focus-visible:outline-none disabled:opacity-60 aria-[invalid=true]:border-danger-700" + /> + +
+ {error ? ( + + ) : ( +

+ 공개된 페이지만 등록할 수 있어요. 본문을 읽어 이력서와 같은 방식으로 분석합니다. +

+ )} + + ) +} diff --git a/frontend/src/pages/Workspace/ui/ResumesView.tsx b/frontend/src/pages/Workspace/ui/ResumesView.tsx index c1e85145..85bad34e 100644 --- a/frontend/src/pages/Workspace/ui/ResumesView.tsx +++ b/frontend/src/pages/Workspace/ui/ResumesView.tsx @@ -1,5 +1,5 @@ import { WorkspaceSection } from '@/widgets/workspace-section' -import { ResumeList, ResumeUploader } from '@/features/resume' +import { ResumeList, ResumeUploader, WebResumeForm } from '@/features/resume' import { DocumentList } from '@/features/analysis' export function ResumesView() { @@ -7,11 +7,15 @@ export function ResumesView() {
+ {/* 파일과 링크는 같은 자료 목록으로 합쳐진다(서버에서도 같은 resume 도메인). */} +
+ +
@@ -19,7 +23,7 @@ export function ResumesView() { diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 655e1998..4860eb61 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -197,6 +197,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/resumes/web": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 웹 이력서(URL) 등록 + 분석 트리거 (US-09) + * @description 포트폴리오·블로그·노션 등 공개 URL 을 이력서 자료로 등록한다. 파일 업로드 없이 URL 만 저장하고, 본문 추출·요약·임베딩은 AI 서버가 수행(analyze.web). 결과는 /realtime/stream/me (DOC_STATE) 로 통지된다. http·https 공개 주소만 허용(내부망 차단). + */ + post: operations["registerWebResume"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/repositories": { parameters: { query?: never; @@ -1029,9 +1049,10 @@ export interface components { originalFilename?: string; filePath?: string; /** @enum {string} */ - fileType?: "PDF"; + fileType?: "PDF" | "WEB"; /** Format: int64 */ fileSize?: number; + sourceUrl?: string; /** @enum {string} */ status?: "PENDING" | "ANALYZING" | "ANALYZED" | "FAILED"; /** Format: date-time */ @@ -1039,6 +1060,9 @@ export interface components { /** Format: date-time */ updatedAt?: string; }; + WebResumeCreateRequest: { + url: string; + }; RegisterRepositoryRequest: { /** Format: int64 */ githubRepoId: number; @@ -2096,6 +2120,57 @@ export interface operations { }; }; }; + registerWebResume: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebResumeCreateRequest"]; + }; + }; + responses: { + /** @description 등록 + 분석 트리거 성공 */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ResumeResponse"]; + }; + }; + /** @description URL 형식 오류 / 비-http(s) / 내부망 주소 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ResumeResponse"]; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ResumeResponse"]; + }; + }; + /** @description 이미 등록된 URL */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ResumeResponse"]; + }; + }; + }; + }; listRegisteredRepositories: { parameters: { query?: never;