fix: 공유 미리보기가 깨지던 오류 수정
This commit is contained in:
parent
048a8f36a8
commit
a727d4f1ac
@ -70,6 +70,7 @@ PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
|||||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
||||||
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net)
|
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)
|
SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png)
|
||||||
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ from app.video.services.share_page import (
|
|||||||
build_ssul_share_html,
|
build_ssul_share_html,
|
||||||
get_ssul_share_data,
|
get_ssul_share_data,
|
||||||
resolve_frontend_base_url,
|
resolve_frontend_base_url,
|
||||||
|
resolve_share_url,
|
||||||
)
|
)
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import prj_settings, ssulbox_settings
|
from config import prj_settings, ssulbox_settings
|
||||||
@ -205,7 +206,11 @@ async def get_ssul_share_page(
|
|||||||
if share_data is None:
|
if share_data is None:
|
||||||
raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.")
|
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(
|
html = build_ssul_share_html(
|
||||||
share_data,
|
share_data,
|
||||||
share_url=share_url,
|
share_url=share_url,
|
||||||
|
|||||||
@ -65,6 +65,7 @@ from app.video.services.share_page import (
|
|||||||
build_video_share_html,
|
build_video_share_html,
|
||||||
get_video_share_data,
|
get_video_share_data,
|
||||||
resolve_frontend_base_url,
|
resolve_frontend_base_url,
|
||||||
|
resolve_share_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1106,7 +1107,11 @@ async def get_video_share_page(
|
|||||||
if share_data is None:
|
if share_data is None:
|
||||||
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
|
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(
|
html = build_video_share_html(
|
||||||
share_data,
|
share_data,
|
||||||
share_url=share_url,
|
share_url=share_url,
|
||||||
|
|||||||
@ -169,13 +169,13 @@ def _build_share_html(
|
|||||||
share_url=share_url,
|
share_url=share_url,
|
||||||
)
|
)
|
||||||
image_url = _absolute_http_url(poster_url) or fallback_image_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)
|
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_title = escape(title, quote=True)
|
||||||
escaped_description = escape(description, quote=True)
|
escaped_description = escape(description, quote=True)
|
||||||
escaped_image_url = escape(image_url, 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)
|
escaped_detail_url = escape(detail_url, quote=True)
|
||||||
|
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
@ -185,14 +185,12 @@ def _build_share_html(
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{escaped_title}</title>
|
<title>{escaped_title}</title>
|
||||||
<meta name="description" content="{escaped_description}">
|
<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:title" content="{escaped_title}">
|
||||||
<meta property="og:description" content="{escaped_description}">
|
<meta property="og:description" content="{escaped_description}">
|
||||||
<meta property="og:image" content="{escaped_image_url}">
|
<meta property="og:image" content="{escaped_image_url}">
|
||||||
<meta property="og:image:alt" content="{escaped_title}">
|
<meta property="og:image:alt" content="{escaped_title}">
|
||||||
{image_size_tags} <meta property="og:url" content="{escaped_canonical_url}">
|
{image_size_tags} <meta property="og:type" content="website">
|
||||||
<meta property="og:type" content="website">
|
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="{escaped_title}">
|
<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}")
|
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:
|
def _normalise_frontend_base_url(value: str) -> str:
|
||||||
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
|
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
|
||||||
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_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}"
|
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)
|
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,14 @@ class ProjectSettings(BaseSettings):
|
|||||||
default="https://ado2.o2osolution.ai",
|
default="https://ado2.o2osolution.ai",
|
||||||
description="공유 페이지에서 영상 상세로 이동할 프론트엔드 공개 기준 URL (.env: SHARE_FRONTEND_URL)",
|
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(
|
SHARE_DEFAULT_IMAGE_URL: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description=(
|
description=(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user