315 lines
11 KiB
Python
315 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, field_validator
|
|
|
|
from .. import session_persistence
|
|
from ..session_read_model import StageLabel, stage_label_or_none
|
|
|
|
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: StageLabel | None = None
|
|
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 = ""
|
|
|
|
@field_validator("reachedPhase", mode="before")
|
|
@classmethod
|
|
def _normalize_reached_phase(cls, value: object) -> StageLabel | None:
|
|
return stage_label_or_none(value)
|
|
|
|
|
|
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=payload.get("reachedPhase"),
|
|
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"})
|