vignette/apps/api/app/routes/share.py
2026-06-28 12:18:20 +09:00

309 lines
11 KiB
Python

"""Public share/unfurl routes.
공개 공유 URL은 세션 권한을 우회하지 않는다. 학습자가 명시적으로 생성한
토큰으로 app.session_share_link의 sanitized payload만 읽고, 원문 축어록은 조회하지 않는다.
"""
from __future__ import annotations
import html
import json
import re
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, PlainTextResponse
from pydantic import BaseModel, Field
from .. import session_persistence
router = APIRouter(tags=["share"])
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,160}$")
_SHARE_HEADERS = {
"cache-control": "public, max-age=300",
"x-robots-tag": "noindex, noarchive, max-snippet:160",
}
class PublicSessionShareResponse(BaseModel):
title: str
description: str
summary: str
imageUrl: str
appUrl: str
clientName: str
persona: str
date: str
durationLabel: str
reachedPhase: str
sessionSignal: str
reviewReady: bool = False
goodMoments: list[str] = Field(default_factory=list)
growthPoints: list[str] = Field(default_factory=list)
worksheetHighlights: list[dict[str, str]] = Field(default_factory=list)
privacy: str = ""
def _safe_payload(payload: dict[str, Any]) -> PublicSessionShareResponse:
return PublicSessionShareResponse(
title=str(payload.get("title") or "Vignette 회기 리뷰"),
description=str(payload.get("description") or "AI 심리상담 시뮬레이션 회기 리뷰 요약"),
summary=str(payload.get("summary") or ""),
imageUrl=str(payload.get("imageUrl") or ""),
appUrl=str(payload.get("appUrl") or ""),
clientName=str(payload.get("clientName") or "내담자"),
persona=str(payload.get("persona") or ""),
date=str(payload.get("date") or ""),
durationLabel=str(payload.get("durationLabel") or ""),
reachedPhase=str(payload.get("reachedPhase") or ""),
sessionSignal=str(payload.get("sessionSignal") or ""),
reviewReady=bool(payload.get("reviewReady")),
goodMoments=[str(item) for item in payload.get("goodMoments") or []][:3],
growthPoints=[str(item) for item in payload.get("growthPoints") or []][:3],
worksheetHighlights=[
{
"section": str(item.get("section") or ""),
"label": str(item.get("label") or ""),
"value": str(item.get("value") or ""),
}
for item in (payload.get("worksheetHighlights") or [])
if isinstance(item, dict)
][:4],
privacy=str(payload.get("privacy") or ""),
)
async def _load_share_or_404(token: str) -> PublicSessionShareResponse:
if not _TOKEN_RE.match(token):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
record = await session_persistence.load_public_session_share(
session_persistence.share_token_hash(token)
)
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
return _safe_payload(dict(record.get("payload") or {}))
def _request_url(request: Request) -> str:
return str(request.url)
def _json_ld(share: PublicSessionShareResponse, url: str) -> str:
payload = {
"@context": "https://schema.org",
"@type": "CreativeWork",
"name": share.title,
"description": share.description,
"url": url,
"image": share.imageUrl,
"inLanguage": "ko-KR",
"educationalUse": "AI counseling simulation review",
"isAccessibleForFree": True,
"provider": {
"@type": "Organization",
"name": "Vignette",
},
}
return json.dumps(payload, ensure_ascii=False)
def _meta(name: str, content: str, *, prop: bool = False) -> str:
attr = "property" if prop else "name"
return f'<meta {attr}="{html.escape(name)}" content="{html.escape(content, quote=True)}">'
def _list_items(values: list[str]) -> str:
if not values:
return "<li>아직 공유 가능한 항목이 없습니다.</li>"
return "".join(f"<li>{html.escape(value)}</li>" for value in values)
def _worksheet_items(values: list[dict[str, str]]) -> str:
if not values:
return "<li>사례개념화 워크시트 핵심값은 아직 비어 있습니다.</li>"
return "".join(
"<li>"
f"<b>{html.escape(item['label'])}</b>"
f"<span>{html.escape(item['value'])}</span>"
"</li>"
for item in values
)
def _share_html(share: PublicSessionShareResponse, url: str) -> str:
title = html.escape(share.title)
description = html.escape(share.description)
image = html.escape(share.imageUrl, quote=True)
app_url = html.escape(share.appUrl or "https://vignette.chanpaca.net", quote=True)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
{_meta("robots", "noindex, noarchive, max-snippet:160")}
{_meta("description", share.description)}
{_meta("og:type", "article", prop=True)}
{_meta("og:site_name", "Vignette", prop=True)}
{_meta("og:title", share.title, prop=True)}
{_meta("og:description", share.description, prop=True)}
{_meta("og:url", url, prop=True)}
{_meta("og:image", share.imageUrl, prop=True)}
{_meta("og:image:width", "1672", prop=True)}
{_meta("og:image:height", "941", prop=True)}
{_meta("twitter:card", "summary_large_image")}
{_meta("twitter:title", share.title)}
{_meta("twitter:description", share.description)}
{_meta("twitter:image", share.imageUrl)}
<script type="application/ld+json">{_json_ld(share, url)}</script>
<style>
:root {{
color-scheme: light dark;
--bg: #f8f5ef;
--surface: #ffffff;
--ink: #172424;
--muted: #65706d;
--accent: #2f6f63;
--line: #e4ddd2;
}}
body {{
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
line-height: 1.55;
}}
main {{
width: min(920px, calc(100% - 32px));
margin: 0 auto;
padding: 44px 0;
}}
.hero {{
overflow: hidden;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
box-shadow: 0 14px 40px rgba(23, 36, 36, .08);
}}
.hero img {{
display: block;
width: 100%;
aspect-ratio: 1672 / 941;
object-fit: cover;
}}
.body {{ padding: 28px; }}
.eyebrow {{
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}}
h1 {{ margin: 0; font-size: clamp(26px, 4vw, 42px); line-height: 1.2; letter-spacing: 0; }}
.desc {{ margin: 14px 0 0; color: var(--muted); font-size: 17px; }}
.facts {{
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 24px 0;
}}
.fact {{ border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: color-mix(in srgb, var(--surface) 82%, var(--bg)); }}
.fact b {{ display: block; font-size: 12px; color: var(--muted); }}
.fact span {{ display: block; margin-top: 4px; font-weight: 750; }}
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
section {{ border-top: 1px solid var(--line); padding-top: 18px; }}
h2 {{ margin: 0 0 10px; font-size: 17px; }}
ul {{ margin: 0; padding-left: 20px; color: var(--ink); }}
li + li {{ margin-top: 8px; }}
li span {{ display: block; color: var(--muted); }}
.privacy {{ margin-top: 22px; color: var(--muted); font-size: 13px; }}
.cta {{
display: inline-flex;
align-items: center;
min-height: 42px;
margin-top: 22px;
padding: 0 16px;
border-radius: 8px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 800;
}}
@media (max-width: 720px) {{
main {{ width: min(100% - 20px, 920px); padding: 20px 0; }}
.body {{ padding: 20px; }}
.facts, .grid {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<main>
<article class="hero">
<img src="{image}" alt="">
<div class="body">
<p class="eyebrow">Vignette session review</p>
<h1>{title}</h1>
<p class="desc">{description}</p>
<div class="facts" aria-label="회기 요약">
<div class="fact"><b>날짜</b><span>{html.escape(share.date or "-")}</span></div>
<div class="fact"><b>시간</b><span>{html.escape(share.durationLabel or "-")}</span></div>
<div class="fact"><b>도달 단계</b><span>{html.escape(share.reachedPhase or "-")}</span></div>
<div class="fact"><b>상태</b><span>{html.escape(share.sessionSignal or "-")}</span></div>
</div>
<div class="grid">
<section>
<h2>강점 요약</h2>
<ul>{_list_items(share.goodMoments)}</ul>
</section>
<section>
<h2>개선 요약</h2>
<ul>{_list_items(share.growthPoints)}</ul>
</section>
</div>
<section style="margin-top:18px">
<h2>사례개념화 핵심값</h2>
<ul>{_worksheet_items(share.worksheetHighlights)}</ul>
</section>
<p class="privacy">{html.escape(share.privacy)}</p>
<a class="cta" href="{app_url}">Vignette 열기</a>
</div>
</article>
</main>
</body>
</html>"""
@router.get("/share/session/{token}", response_class=HTMLResponse, name="get_public_session_share")
async def get_public_session_share(token: str, request: Request) -> HTMLResponse:
share = await _load_share_or_404(token)
return HTMLResponse(_share_html(share, _request_url(request)), headers=_SHARE_HEADERS)
@router.get("/share/session/{token}/summary", response_model=PublicSessionShareResponse)
async def get_public_session_share_summary(token: str, response: Response) -> PublicSessionShareResponse:
for key, value in _SHARE_HEADERS.items():
response.headers[key] = value
return await _load_share_or_404(token)
@router.get("/robots.txt", include_in_schema=False)
async def robots_txt() -> PlainTextResponse:
body = "\n".join(
[
"User-agent: *",
"Disallow: /auth/",
"Disallow: /admin/",
"Disallow: /sessions/",
"Disallow: /teacher/",
"Disallow: /users/",
"Disallow: /eval/",
"Disallow: /voice/",
"Disallow: /kb/",
"Disallow: /share/",
"",
]
)
return PlainTextResponse(body, headers={"cache-control": "public, max-age=3600"})