"""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'' def _list_items(values: list[str]) -> str: if not values: return "
  • 아직 공유 가능한 항목이 없습니다.
  • " return "".join(f"
  • {html.escape(value)}
  • " for value in values) def _worksheet_items(values: list[dict[str, str]]) -> str: if not values: return "
  • 사례개념화 워크시트 핵심값은 아직 비어 있습니다.
  • " return "".join( "
  • " f"{html.escape(item['label'])}" f"{html.escape(item['value'])}" "
  • " 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""" {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)}

    Vignette session review

    {title}

    {description}

    날짜{html.escape(share.date or "-")}
    시간{html.escape(share.durationLabel or "-")}
    도달 단계{html.escape(share.reachedPhase or "-")}
    상태{html.escape(share.sessionSignal or "-")}

    강점 요약

      {_list_items(share.goodMoments)}

    개선 요약

      {_list_items(share.growthPoints)}

    사례개념화 핵심값

      {_worksheet_items(share.worksheetHighlights)}

    {html.escape(share.privacy)}

    Vignette 열기
    """ @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"})