From a1d321c86d4244a53c58ef215de4953ba9671896 Mon Sep 17 00:00:00 2001 From: jmj Date: Fri, 21 Aug 2026 00:07:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=EA=B3=B5=EC=9C=A0=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=A7=81=ED=81=AC=EA=B0=80=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=97=94=EC=A7=84=EC=97=90=20=EC=83=89=EC=9D=B8?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /share/ 은 "링크를 받은 사람만" 보라고 만든 페이지인데 색인을 막는 장치가 하나도 없었다(robots.txt 없음, meta robots 없음). 사용자가 그 링크를 블로그나 커뮤니티에 붙이면 크롤러가 따라 들어와 리포트 전문 — 강점·약점·학습 방향, 전부 본인 이력서에서 파생된 내용 — 을 색인한다. 토큰은 UUIDv4 라 추측으로는 못 찾지만, 링크 유출은 공유 기능의 정상 사용 과정에서 일어난다. 1차 방어는 robots.txt Disallow 다. 프론트가 정적 파일로 배포돼 응답 헤더 (X-Robots-Tag)를 붙일 수 없어서 크롤링 자체를 막는 쪽을 택했다. 2차로 useNoIndex 훅이 공유 페이지에서만 meta robots 를 붙였다 뗀다 — robots.txt 를 무시하면서 JS 는 실행하는 크롤러 대비. index.html 에 정적으로 넣지 않은 건 SPA 라 문서가 하나뿐이라서다. 그렇게 하면 랜딩 페이지까지 통째로 색인에서 빠진다. --- frontend/public/robots.txt | 12 +++++++++++ .../SharedFeedback/ui/SharedFeedbackPage.tsx | 3 +++ frontend/src/shared/hooks/index.ts | 1 + frontend/src/shared/hooks/useNoIndex.test.ts | 19 ++++++++++++++++++ frontend/src/shared/hooks/useNoIndex.ts | 20 +++++++++++++++++++ 5 files changed, 55 insertions(+) create mode 100644 frontend/public/robots.txt create mode 100644 frontend/src/shared/hooks/useNoIndex.test.ts create mode 100644 frontend/src/shared/hooks/useNoIndex.ts diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..eeb8c1c --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,12 @@ +# 공유 피드백 링크(/share/)는 "링크를 받은 사람만" 보라고 만든 것이다. +# 사용자가 그 링크를 블로그·커뮤니티에 붙이면 크롤러가 따라 들어와 리포트 전문 +# (강점·약점·학습 방향 — 전부 본인 이력서에서 파생된 내용)을 색인한다. +# 프론트는 정적 파일로 배포돼 응답 헤더(X-Robots-Tag)를 붙일 수 없으므로 +# 크롤링 자체를 막는다. 토큰은 UUIDv4 라 추측으로는 못 찾고, 유출 경로는 링크뿐이다. +User-agent: * +Disallow: /share/ + +# 로그인해야 내용이 보이는 경로 — 크롤러에겐 빈 셸만 나가므로 색인할 가치가 없다. +Disallow: /workspace +Disallow: /sessions/ +Disallow: /auth/ diff --git a/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx b/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx index 6ad6e46..cc9466e 100644 --- a/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx +++ b/frontend/src/pages/SharedFeedback/ui/SharedFeedbackPage.tsx @@ -3,9 +3,12 @@ import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' import { FeedbackReport, useSharedFeedback } from '@/features/feedback' import { PageHeader } from '@/shared/ui' +import { useNoIndex } from '@/shared/hooks' // 공유 토큰으로 피드백을 보는 공개(비로그인) 페이지. export default function SharedFeedbackPage() { + // 링크를 받은 사람만 보라고 만든 페이지다 — 검색 결과에 뜨면 안 된다. + useNoIndex() const { token } = useParams<{ token: string }>() const { data, isLoading, isError } = useSharedFeedback(token ?? '') diff --git a/frontend/src/shared/hooks/index.ts b/frontend/src/shared/hooks/index.ts index 3c23ce3..e437e86 100644 --- a/frontend/src/shared/hooks/index.ts +++ b/frontend/src/shared/hooks/index.ts @@ -3,3 +3,4 @@ export { useAnalysisProgress, analysisProgress } from './useAnalysisProgress' export type { AnalysisProgress } from './useAnalysisProgress' export { useCopyToClipboard } from './useCopyToClipboard' export { useQuestionRunner } from './useQuestionRunner' +export { useNoIndex } from './useNoIndex' diff --git a/frontend/src/shared/hooks/useNoIndex.test.ts b/frontend/src/shared/hooks/useNoIndex.test.ts new file mode 100644 index 0000000..91b073d --- /dev/null +++ b/frontend/src/shared/hooks/useNoIndex.test.ts @@ -0,0 +1,19 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useNoIndex } from './useNoIndex' + +const robotsMeta = () => document.head.querySelector('meta[name="robots"]') + +describe('useNoIndex', () => { + it('마운트 동안 robots noindex 를 심는다', () => { + renderHook(() => useNoIndex()) + expect(robotsMeta()?.getAttribute('content')).toContain('noindex') + }) + + // 다른 페이지로 이동했는데 태그가 남으면 앱 전체가 색인에서 빠진다. + it('언마운트하면 걷어낸다', () => { + const { unmount } = renderHook(() => useNoIndex()) + unmount() + expect(robotsMeta()).toBeNull() + }) +}) diff --git a/frontend/src/shared/hooks/useNoIndex.ts b/frontend/src/shared/hooks/useNoIndex.ts new file mode 100644 index 0000000..fc1939f --- /dev/null +++ b/frontend/src/shared/hooks/useNoIndex.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react' + +/** + * 이 페이지가 떠 있는 동안 검색엔진 색인을 거부한다. + * + *

`robots.txt` 로 크롤링을 막는 게 1차 방어지만 그건 규칙을 지키는 크롤러에게만 + * 통한다. JS 는 실행하면서 robots.txt 는 무시하는 쪽을 위한 2차 방어다. + * + *

index.html 에 정적으로 넣을 수 없다 — SPA 라 문서가 하나뿐이라서, 그렇게 하면 + * 랜딩·소개 페이지까지 통째로 색인에서 빠진다. 그래서 페이지 단위로 붙였다 뗀다. + */ +export function useNoIndex(): void { + useEffect(() => { + const meta = document.createElement('meta') + meta.name = 'robots' + meta.content = 'noindex, nofollow, noarchive' + document.head.appendChild(meta) + return () => meta.remove() + }, []) +}