fix: 공유 미리보기가 깨지던 오류 수정

This commit is contained in:
김성경 2026-08-20 13:39:17 +09:00
parent 048a8f36a8
commit a727d4f1ac
5 changed files with 95 additions and 8 deletions

View File

@ -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: 운영)

View File

@ -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,

View File

@ -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,

View File

@ -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"""<!doctype html>
@ -185,14 +185,12 @@ def _build_share_html(
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<meta name="description" content="{escaped_description}">
<link rel="canonical" href="{escaped_canonical_url}">
{canonical_tags}
<meta property="og:title" content="{escaped_title}">
<meta property="og:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}">
<meta property="og:image:alt" content="{escaped_title}">
{image_size_tags} <meta property="og:url" content="{escaped_canonical_url}">
<meta property="og:type" content="website">
{image_size_tags} <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{escaped_title}">
@ -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' <link rel="canonical" href="{escaped}">\n'
f' <meta property="og:url" content="{escaped}">\n'
)
_DEFAULT_OG_IMAGE_SIZE = (385, 385)

View File

@ -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=(