fix: 공유 미리보기가 깨지던 오류 수정
This commit is contained in:
parent
048a8f36a8
commit
a727d4f1ac
@ -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: 운영)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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=(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user