From a727d4f1ac82c9b40775530761728029adb712cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Thu, 20 Aug 2026 13:39:17 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EA=B0=80=20=EA=B9=A8=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + app/ssulbox/api/routers/v1/content.py | 7 ++- app/video/api/routers/v1/video.py | 7 ++- app/video/services/share_page.py | 80 +++++++++++++++++++++++++-- config.py | 8 +++ 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b79bbb8..119d80c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ PROJECT_VERSION=0.1.0 # 프로젝트 버전 DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명 ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net) +SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수 SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png) DEBUG=True # 디버그 모드 (True: 개발, False: 운영) diff --git a/app/ssulbox/api/routers/v1/content.py b/app/ssulbox/api/routers/v1/content.py index 1d8b92f..29706fc 100644 --- a/app/ssulbox/api/routers/v1/content.py +++ b/app/ssulbox/api/routers/v1/content.py @@ -33,6 +33,7 @@ from app.video.services.share_page import ( build_ssul_share_html, get_ssul_share_data, resolve_frontend_base_url, + resolve_share_url, ) from app.utils.logger import get_logger from config import prj_settings, ssulbox_settings @@ -205,7 +206,11 @@ async def get_ssul_share_page( if share_data is None: raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.") - share_url = str(request.url).split("?", maxsplit=1)[0] + share_url = resolve_share_url( + request.headers, + str(request.url).split("?", maxsplit=1)[0], + prj_settings.SHARE_API_BASE_URL, + ) html = build_ssul_share_html( share_data, share_url=share_url, diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 64e463b..3ff78d3 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -65,6 +65,7 @@ from app.video.services.share_page import ( build_video_share_html, get_video_share_data, resolve_frontend_base_url, + resolve_share_url, ) @@ -1106,7 +1107,11 @@ async def get_video_share_page( if share_data is None: raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.") - share_url = str(request.url).split("?", maxsplit=1)[0] + share_url = resolve_share_url( + request.headers, + str(request.url).split("?", maxsplit=1)[0], + prj_settings.SHARE_API_BASE_URL, + ) html = build_video_share_html( share_data, share_url=share_url, diff --git a/app/video/services/share_page.py b/app/video/services/share_page.py index 16dc184..632b868 100644 --- a/app/video/services/share_page.py +++ b/app/video/services/share_page.py @@ -169,13 +169,13 @@ def _build_share_html( share_url=share_url, ) image_url = _absolute_http_url(poster_url) or fallback_image_url - canonical_url = _absolute_http_url(share_url) or detail_url + canonical_url = _absolute_http_url(share_url) image_size_tags = _og_image_size_tags(image_url, fallback_image_url) + canonical_tags = _canonical_tags(canonical_url) escaped_title = escape(title, quote=True) escaped_description = escape(description, quote=True) escaped_image_url = escape(image_url, quote=True) - escaped_canonical_url = escape(canonical_url, quote=True) escaped_detail_url = escape(detail_url, quote=True) return f""" @@ -185,14 +185,12 @@ def _build_share_html( {escaped_title} - - +{canonical_tags} -{image_size_tags} - +{image_size_tags} @@ -260,6 +258,60 @@ def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str: return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}") +def resolve_share_url( + headers: Mapping[str, str], + request_url: str, + configured_api_base_url: str = "", +) -> str: + """크롤러가 다시 읽어도 같은 OG 페이지가 나오는 공유 URL을 만듭니다. + + nginx가 ``/api`` prefix를 떼고 넘기면 ``request.url``에는 그 prefix가 없어, + 그대로 쓰면 프론트 SPA 주소가 된다. 복원할 근거가 없으면 빈 문자열을 돌려 + 호출부가 canonical/og:url을 생략하도록 한다. + """ + path = urlsplit(request_url).path + + configured_base = _absolute_http_url(configured_api_base_url) + if configured_base: + return f"{configured_base.rstrip('/')}{path}" + + origin = _forwarded_origin(headers) or _origin_from_url(request_url) + if not origin: + return "" + + forwarded_prefix = (headers.get("x-forwarded-prefix") or "").strip().rstrip("/") + if forwarded_prefix: + return f"{origin}{forwarded_prefix}{path}" + + if headers.get("x-forwarded-host"): + # 프록시 뒤인데 prefix 를 못 받았다. 잘못된 URL 을 내보내지 않는다. + return "" + + return f"{origin}{path}" + + +def _forwarded_origin(headers: Mapping[str, str]) -> str | None: + """프록시가 넘긴 외부 호스트 기준 origin을 만듭니다.""" + forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip() + if not forwarded_host: + return None + + forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower() + if forwarded_proto not in {"http", "https"}: + forwarded_proto = "https" + return f"{forwarded_proto}://{forwarded_host}" + + +def _origin_from_url(value: str) -> str | None: + """URL에서 scheme + host(+port) origin만 추출합니다.""" + absolute_url = _absolute_http_url(value) + if not absolute_url: + return None + + parts = urlsplit(absolute_url) + return urlunsplit((parts.scheme, parts.netloc, "", "", "")) + + def _normalise_frontend_base_url(value: str) -> str: """프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다.""" absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL @@ -293,6 +345,22 @@ def _resolve_default_image_url( return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}" +def _canonical_tags(canonical_url: str | None) -> str: + """공유 URL을 확신할 때만 canonical/og:url을 붙입니다. + + 잘못된 og:url을 내보내면 크롤러가 그 주소를 다시 읽어, OG 메타가 없는 + 프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은 + URL을 쓰도록 태그 자체를 생략한다. + """ + if not canonical_url: + return "" + escaped = escape(canonical_url, quote=True) + return ( + f' \n' + f' \n' + ) + + _DEFAULT_OG_IMAGE_SIZE = (385, 385) diff --git a/config.py b/config.py index ce8a989..7d9f130 100644 --- a/config.py +++ b/config.py @@ -37,6 +37,14 @@ class ProjectSettings(BaseSettings): default="https://ado2.o2osolution.ai", description="공유 페이지에서 영상 상세로 이동할 프론트엔드 공개 기준 URL (.env: SHARE_FRONTEND_URL)", ) + SHARE_API_BASE_URL: str = Field( + default="", + description=( + "공유 OG 페이지가 외부에 노출되는 API 기준 URL (.env: SHARE_API_BASE_URL). " + "예: https://dev-ssul.castad.net/api. 프록시가 /api prefix 를 떼고 넘기면 " + "request.url 로는 복원할 수 없으므로 이 값이 필요하다" + ), + ) SHARE_DEFAULT_IMAGE_URL: str = Field( default="", description=(