fix: 공유하기 기능 수정
This commit is contained in:
parent
e47a97476d
commit
5fcb964d7f
@ -5,7 +5,8 @@ castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `
|
|||||||
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
|
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.credit.exceptions import InsufficientCreditError
|
from app.credit.exceptions import InsufficientCreditError
|
||||||
@ -28,8 +29,13 @@ from app.user.dependencies.auth import get_current_user, get_current_user_option
|
|||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
|
# 좋아요는 castad video_reaction 에 병합돼 있다 (썰박스 행은 content_id 가 채워짐)
|
||||||
from app.video.models import VideoReaction
|
from app.video.models import VideoReaction
|
||||||
|
from app.video.services.share_page import (
|
||||||
|
build_ssul_share_html,
|
||||||
|
get_ssul_share_data,
|
||||||
|
resolve_frontend_base_url,
|
||||||
|
)
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import ssulbox_settings
|
from config import prj_settings, ssulbox_settings
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
logger = get_logger("ssulbox")
|
logger = get_logger("ssulbox")
|
||||||
@ -179,6 +185,46 @@ async def get_task(
|
|||||||
return SsulTaskStatus.model_validate(row)
|
return SsulTaskStatus.model_validate(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/share/{content_id}",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
summary="썰박스 공유용 Open Graph 페이지",
|
||||||
|
description="콘텐츠별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
|
||||||
|
responses={
|
||||||
|
200: {"description": "공유 메타데이터 HTML 반환"},
|
||||||
|
404: {"description": "공유 가능한 완료 콘텐츠를 찾을 수 없음"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_ssul_share_page(
|
||||||
|
content_id: int,
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""공개 공유 페이지를 반환하고 일반 브라우저는 썰 상세로 이동시킵니다."""
|
||||||
|
share_data = await get_ssul_share_data(session, content_id)
|
||||||
|
if share_data is None:
|
||||||
|
raise HTTPException(status_code=404, detail="공유 가능한 콘텐츠를 찾을 수 없습니다.")
|
||||||
|
|
||||||
|
share_url = str(request.url).split("?", maxsplit=1)[0]
|
||||||
|
html = build_ssul_share_html(
|
||||||
|
share_data,
|
||||||
|
share_url=share_url,
|
||||||
|
frontend_base_url=resolve_frontend_base_url(
|
||||||
|
request.headers,
|
||||||
|
prj_settings.SHARE_FRONTEND_URL,
|
||||||
|
),
|
||||||
|
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
content=html,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{content_id}",
|
"/{content_id}",
|
||||||
response_model=SsulDetailResponse,
|
response_model=SsulDetailResponse,
|
||||||
|
|||||||
@ -61,7 +61,11 @@ from app.video.worker.video_task import (
|
|||||||
_fail_and_refund,
|
_fail_and_refund,
|
||||||
download_and_upload_video_to_blob,
|
download_and_upload_video_to_blob,
|
||||||
)
|
)
|
||||||
from app.video.services.share_page import build_video_share_html, get_video_share_data
|
from app.video.services.share_page import (
|
||||||
|
build_video_share_html,
|
||||||
|
get_video_share_data,
|
||||||
|
resolve_frontend_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
from config import creatomate_settings, prj_settings
|
from config import creatomate_settings, prj_settings
|
||||||
@ -959,6 +963,8 @@ async def get_all_videos(
|
|||||||
store_name=it.store_name,
|
store_name=it.store_name,
|
||||||
result_movie_url=it.movie_url,
|
result_movie_url=it.movie_url,
|
||||||
poster_url=it.poster_url,
|
poster_url=it.poster_url,
|
||||||
|
title=it.title,
|
||||||
|
description=it.description,
|
||||||
created_at=it.created_at,
|
created_at=it.created_at,
|
||||||
like_count=it.like_count,
|
like_count=it.like_count,
|
||||||
is_liked_by_me=it.is_liked_by_me,
|
is_liked_by_me=it.is_liked_by_me,
|
||||||
@ -1104,7 +1110,10 @@ async def get_video_share_page(
|
|||||||
html = build_video_share_html(
|
html = build_video_share_html(
|
||||||
share_data,
|
share_data,
|
||||||
share_url=share_url,
|
share_url=share_url,
|
||||||
frontend_base_url=prj_settings.SHARE_FRONTEND_URL,
|
frontend_base_url=resolve_frontend_base_url(
|
||||||
|
request.headers,
|
||||||
|
prj_settings.SHARE_FRONTEND_URL,
|
||||||
|
),
|
||||||
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
|
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
|
||||||
)
|
)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
@ -1196,6 +1205,8 @@ async def get_video_detail(
|
|||||||
poster_url=video.poster_url,
|
poster_url=video.poster_url,
|
||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region or _extract_region_from_address(project.detail_region_info),
|
region=project.region or _extract_region_from_address(project.detail_region_info),
|
||||||
|
title=video.title,
|
||||||
|
description=video.description,
|
||||||
created_at=video.created_at,
|
created_at=video.created_at,
|
||||||
like_count=like_count,
|
like_count=like_count,
|
||||||
is_liked_by_me=is_liked_by_me,
|
is_liked_by_me=is_liked_by_me,
|
||||||
|
|||||||
@ -201,6 +201,8 @@ class VideoThumbnailItem(BaseModel):
|
|||||||
store_name: str = Field(..., description="업체명")
|
store_name: str = Field(..., description="업체명")
|
||||||
result_movie_url: str = Field(..., description="영상 URL")
|
result_movie_url: str = Field(..., description="영상 URL")
|
||||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
|
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
|
||||||
|
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||||
|
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
like_count: int = Field(..., description="좋아요 수")
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||||
@ -219,6 +221,8 @@ class VideoDetailResponse(BaseModel):
|
|||||||
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
|
title: Optional[str] = Field(None, description="SNS 업로드 제목")
|
||||||
|
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
like_count: int = Field(..., description="좋아요 수")
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
|
"""콘텐츠 공유 링크용 Open Graph HTML 생성 서비스."""
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from html import escape
|
from html import escape
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
@ -8,12 +9,14 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
|
from app.ssulbox.models import SsulContent
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
|
||||||
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
|
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
|
||||||
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
|
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class VideoShareData:
|
class VideoShareData:
|
||||||
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
|
||||||
@ -22,6 +25,20 @@ class VideoShareData:
|
|||||||
poster_url: str | None
|
poster_url: str | None
|
||||||
store_name: str
|
store_name: str
|
||||||
region: str
|
region: str
|
||||||
|
title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SsulShareData:
|
||||||
|
"""공유 페이지에 필요한 썰박스 콘텐츠 정보."""
|
||||||
|
|
||||||
|
content_id: int
|
||||||
|
poster_url: str | None
|
||||||
|
store_name: str
|
||||||
|
region: str
|
||||||
|
title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
async def get_video_share_data(
|
async def get_video_share_data(
|
||||||
@ -33,6 +50,8 @@ async def get_video_share_data(
|
|||||||
select(
|
select(
|
||||||
Video.id,
|
Video.id,
|
||||||
Video.poster_url,
|
Video.poster_url,
|
||||||
|
Video.title,
|
||||||
|
Video.description,
|
||||||
Project.store_name,
|
Project.store_name,
|
||||||
Project.region,
|
Project.region,
|
||||||
)
|
)
|
||||||
@ -53,6 +72,43 @@ async def get_video_share_data(
|
|||||||
poster_url=row.poster_url,
|
poster_url=row.poster_url,
|
||||||
store_name=row.store_name,
|
store_name=row.store_name,
|
||||||
region=row.region,
|
region=row.region,
|
||||||
|
title=row.title,
|
||||||
|
description=row.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ssul_share_data(
|
||||||
|
session: AsyncSession,
|
||||||
|
content_id: int,
|
||||||
|
) -> SsulShareData | None:
|
||||||
|
"""공유 가능한 완료 썰박스 콘텐츠를 조회합니다."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
SsulContent.id,
|
||||||
|
SsulContent.poster_url,
|
||||||
|
SsulContent.title,
|
||||||
|
SsulContent.description,
|
||||||
|
SsulContent.store_name,
|
||||||
|
SsulContent.region,
|
||||||
|
).where(
|
||||||
|
SsulContent.id == content_id,
|
||||||
|
SsulContent.status == "done",
|
||||||
|
SsulContent.is_deleted.is_(False),
|
||||||
|
SsulContent.video_url.is_not(None),
|
||||||
|
SsulContent.video_url != "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return SsulShareData(
|
||||||
|
content_id=row.id,
|
||||||
|
poster_url=row.poster_url,
|
||||||
|
store_name=row.store_name,
|
||||||
|
region=row.region or "",
|
||||||
|
title=row.title,
|
||||||
|
description=row.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -64,28 +120,61 @@ def build_video_share_html(
|
|||||||
configured_default_image_url: str = "",
|
configured_default_image_url: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
||||||
|
return _build_share_html(
|
||||||
|
detail_path=f"/video/{data.video_id}",
|
||||||
|
poster_url=data.poster_url,
|
||||||
|
title=_share_title(data.title, data.store_name, fallback_store="ADO2 영상"),
|
||||||
|
description=_share_description(data.description),
|
||||||
|
share_url=share_url,
|
||||||
|
frontend_base_url=frontend_base_url,
|
||||||
|
configured_default_image_url=configured_default_image_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ssul_share_html(
|
||||||
|
data: SsulShareData,
|
||||||
|
*,
|
||||||
|
share_url: str,
|
||||||
|
frontend_base_url: str,
|
||||||
|
configured_default_image_url: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""썰박스 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
|
||||||
|
return _build_share_html(
|
||||||
|
detail_path=f"/ssul/{data.content_id}",
|
||||||
|
poster_url=data.poster_url,
|
||||||
|
title=_share_title(data.title, data.store_name, fallback_store="ADO2 썰"),
|
||||||
|
description=_share_description(data.description),
|
||||||
|
share_url=share_url,
|
||||||
|
frontend_base_url=frontend_base_url,
|
||||||
|
configured_default_image_url=configured_default_image_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_share_html(
|
||||||
|
*,
|
||||||
|
detail_path: str,
|
||||||
|
poster_url: str | None,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
share_url: str,
|
||||||
|
frontend_base_url: str,
|
||||||
|
configured_default_image_url: str,
|
||||||
|
) -> str:
|
||||||
|
"""크롤러용 OG 메타와, 사람용 프론트 상세 이동 링크를 포함한 HTML을 만듭니다."""
|
||||||
frontend_base = _normalise_frontend_base_url(frontend_base_url)
|
frontend_base = _normalise_frontend_base_url(frontend_base_url)
|
||||||
detail_url = f"{frontend_base}/video/{data.video_id}"
|
detail_url = f"{frontend_base}{detail_path}"
|
||||||
fallback_image_url = _resolve_default_image_url(
|
fallback_image_url = _resolve_default_image_url(
|
||||||
configured_default_image_url,
|
configured_default_image_url,
|
||||||
frontend_base,
|
frontend_base,
|
||||||
share_url=share_url,
|
share_url=share_url,
|
||||||
)
|
)
|
||||||
image_url = _absolute_http_url(data.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
|
||||||
store_name = _normalise_text(data.store_name, "ADO2 영상")
|
|
||||||
region = _normalise_text(data.region, "")
|
|
||||||
title = f"{store_name} | ADO2"
|
|
||||||
description = (
|
|
||||||
f"{region} · {store_name} 어떤 내용이 들어가야할지 정해야 합니다. 기존 영상 업로드시에 생성되는 내용을 사용하려면 db에 저장하고 업로드 할때마다 변경되는 부분을 전부 수정해야 합니다."
|
|
||||||
if region
|
|
||||||
else "ADO2 AI 마케팅 영상"
|
|
||||||
)
|
|
||||||
|
|
||||||
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_share_url = escape(_absolute_http_url(share_url) or detail_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>
|
||||||
@ -95,13 +184,13 @@ def build_video_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_share_url}">
|
<link rel="canonical" href="{escaped_canonical_url}">
|
||||||
|
|
||||||
<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}">
|
||||||
<meta property="og:url" content="{escaped_share_url}">
|
<meta property="og:url" content="{escaped_canonical_url}">
|
||||||
<meta property="og:type" content="video.other">
|
<meta property="og:type" content="video.other">
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
@ -114,7 +203,7 @@ def build_video_share_html(
|
|||||||
<main>
|
<main>
|
||||||
<h1>{escaped_title}</h1>
|
<h1>{escaped_title}</h1>
|
||||||
<p>{escaped_description}</p>
|
<p>{escaped_description}</p>
|
||||||
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
|
<a id="continue-link" href="{escaped_detail_url}">콘텐츠 보기</a>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
window.location.replace(document.getElementById("continue-link").href);
|
window.location.replace(document.getElementById("continue-link").href);
|
||||||
@ -130,6 +219,40 @@ def _normalise_text(value: str | None, fallback: str) -> str:
|
|||||||
return normalised or fallback
|
return normalised or fallback
|
||||||
|
|
||||||
|
|
||||||
|
_OG_DESCRIPTION_MAX_LEN = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _share_title(title: str | None, store_name: str, *, fallback_store: str) -> str:
|
||||||
|
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
|
||||||
|
stored = _normalise_text(title, "")
|
||||||
|
if stored:
|
||||||
|
return stored
|
||||||
|
name = _normalise_text(store_name, fallback_store)
|
||||||
|
return f"{name} | ADO2"
|
||||||
|
|
||||||
|
|
||||||
|
def _share_description(description: str | None) -> str:
|
||||||
|
"""저장된 SNS 설명을 쓰고, 없으면 기본 문구를 사용합니다."""
|
||||||
|
stored = _normalise_text(description, "")
|
||||||
|
if stored:
|
||||||
|
if len(stored) > _OG_DESCRIPTION_MAX_LEN:
|
||||||
|
return stored[: _OG_DESCRIPTION_MAX_LEN - 1].rstrip() + "…"
|
||||||
|
return stored
|
||||||
|
return "ADO2 AI 마케팅 영상"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str:
|
||||||
|
"""프록시가 넘긴 프론트 호스트를 우선해 canonical 기준 URL을 정합니다."""
|
||||||
|
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
|
||||||
|
if not forwarded_host:
|
||||||
|
return _normalise_frontend_base_url(fallback)
|
||||||
|
|
||||||
|
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
|
||||||
|
if forwarded_proto not in {"http", "https"}:
|
||||||
|
forwarded_proto = "https"
|
||||||
|
return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}")
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user