feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+179 -6
View File
@@ -14,20 +14,43 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
(phase 14 — raw text, never HTML), so a saved chat restores
pixel-identical through the existing ``renderStoredMessage`` path.
Routes: ``GET`` (list, latest activity first — no payloads), ``POST``
(create — auto-title from the first question when no ``title`` is
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
Routes: ``GET`` (list, latest activity first — no payloads, but the
phase-51 ``share_url`` so the History's Share column renders without a
second fetch), ``POST`` (create — auto-title from the first question
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
the token in the SAME commit — the save-then-share contract),
``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
only when supplied), ``DELETE /{chat_id}``.
only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this
admin-gated router.
Sharing (phase 51, owner-locked 2026-08-29): a saved chat's
``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it
publicly readable at ``/shared/<token>`` — two more routers in this
file, registered in ``app.main`` with **no** admin dependency:
* ``public_router`` — ``GET /shared/{token}`` (mounted under ``/api``
→ ``GET /api/shared/<token>``): the anonymous read, a minimal
``SharedChatOut`` snapshot (no id/timestamps/token); wrong or
revoked tokens 404 with one message (no enumeration).
* ``shared_page_router`` — ``GET /shared/{token}`` (mounted with **no**
prefix, before the static catch-all): serves
``frontend/shared.html`` (the page lands in phase 51, task 03). A
stale deploy without the file 404s with the SAME JSON as the API —
never a 500.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.auth import require_admin
from app.db import get_db
from app.models import SavedChat
@@ -38,6 +61,9 @@ from app.schemas import (
SavedChatOut,
SavedChatRow,
SavedChatUpdate,
SharedChatOut,
ShareOut,
UnshareOut,
)
router = APIRouter(
@@ -65,6 +91,15 @@ def _auto_title(messages: list[ChatMessage]) -> str:
return " ".join(first_user.split())[:_AUTO_TITLE_MAX]
def _share_url(row: SavedChat) -> str | None:
"""The row's share link path (phase 51) — ``None`` when unshared.
``None`` is ABSENT from the JSON (the schemas' omission rule), so an
unshared chat exposes no share surface at all.
"""
return f"/shared/{row.share_token}" if row.share_token else None
def _to_out(row: SavedChat) -> SavedChatOut:
"""The full-payload response shape (create/get/put)."""
return SavedChatOut(
@@ -74,16 +109,19 @@ def _to_out(row: SavedChat) -> SavedChatOut:
updated_at=row.updated_at,
message_count=len(row.messages),
messages=[ChatMessage.model_validate(m) for m in row.messages],
share_url=_share_url(row),
)
def _to_row(row: SavedChat) -> SavedChatRow:
"""The list-page row shape (no payloads in the list)."""
"""The list-page row shape (no payloads in the list; ``share_url``
is not a payload — it is the History Share column's data)."""
return SavedChatRow(
id=row.id,
title=row.title,
updated_at=row.updated_at,
message_count=len(row.messages),
share_url=_share_url(row),
)
@@ -110,6 +148,11 @@ def create_chat(
text, whitespace-collapsed, truncated to 120 chars (owner-locked
convention); a conversation with no user message (defensive) falls
back to ``"Chat <id-hex8>"``.
``share: true`` (phase 51, task 02 — the save-then-share contract):
the fresh row carries ``share_token = uuid.uuid4()`` in the SAME
INSERT/commit — one request saves AND shares, and the 201 body
carries ``share_url`` (the chat page copies it in one action).
"""
title = (payload.title or "").strip() or _auto_title(payload.messages)
row = SavedChat(
@@ -121,6 +164,10 @@ def create_chat(
# restore path is null-safe for every optional key).
messages=[m.model_dump() for m in payload.messages],
)
if payload.share:
# Phase 51: set on the PENDING row, so the token ships in the
# same INSERT (one commit — save and share are one action).
row.share_token = uuid.uuid4()
db.add(row)
db.flush() # python-side uuid default lands the id before the fallback
if not row.title:
@@ -181,3 +228,129 @@ def delete_chat(
db.delete(row)
db.commit()
return Response(status_code=204)
@router.post("/{chat_id}/share", response_model=ShareOut)
def share_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> ShareOut:
"""Turn a saved chat into a public link (phase 51, task 01).
Returns ``{"chat_id", "share_url"}`` with ``share_url =
"/shared/<token>"`` (200, idempotent — an existing token is
returned unchanged; a new token is a 128-bit ``uuid4``).
``updated_at`` is **not** bumped: sharing is not a content edit,
so the token is written with a raw SQL ``UPDATE`` that touches ONLY
``share_token`` — the column's ``onupdate=func.now()`` default is
registered on the Table (via ``mapped_column``), so even a Core
``update(SavedChat)`` DML statement would pick it up; the ORM
object is never mutated either. 404 when the id is unknown.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
token = row.share_token
if token is None:
token = uuid.uuid4()
# Raw SQL on purpose: sets ONLY share_token, so the column's
# onupdate default for ``updated_at`` never fires (the History
# page's "latest activity" order must follow content edits only).
db.execute(
text("UPDATE saved_chats SET share_token = :tok WHERE id = :id"),
{"tok": token, "id": chat_id},
)
db.commit()
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
@router.post("/{chat_id}/unshare", response_model=UnshareOut)
def unshare_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> UnshareOut:
"""Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL.
Idempotent — an unshared chat unshares cleanly (200, no write).
``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only
``share_token``, same reasoning as :func:`share_chat`); 404 when
the id is unknown.
"""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
if row.share_token is not None:
db.execute(
text("UPDATE saved_chats SET share_token = NULL WHERE id = :id"),
{"id": chat_id},
)
db.commit()
return UnshareOut(chat_id=row.id, shared=False)
#: Public read surface (phase 51, task 01) — **no** admin dependency:
#: a guest with the link reads the shared chat anonymously. Mounted
#: under ``/api`` in ``app.main`` → ``GET /api/shared/<token>``.
public_router = APIRouter(tags=["chats"])
def _to_shared_out(row: SavedChat) -> SharedChatOut:
"""The PUBLIC read shape: title + messages only — no id,
timestamps, or token (a content snapshot, not a handle)."""
return SharedChatOut(
title=row.title,
messages=[ChatMessage.model_validate(m) for m in row.messages],
)
@public_router.get("/shared/{token}", response_model=SharedChatOut)
def read_shared_chat(
token: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> SharedChatOut:
"""Anonymous read of a shared chat by token (phase 51, task 01).
No admin dependency — the token IS the credential. A wrong or a
revoked (unshared) token 404s with ONE message: ``unknown or
revoked share link`` — deliberately no enumeration between the two
cases.
"""
row = db.execute(
select(SavedChat).where(SavedChat.share_token == token).limit(1)
).scalars().first()
if row is None:
raise HTTPException(status_code=404, detail="unknown or revoked share link")
return _to_shared_out(row)
#: The share PAGE route (phase 51, task 01) — mounted with **no**
#: prefix, BEFORE the static catch-all in ``app.main`` (the
#: API-routes-first convention): ``/shared/<uuid>`` is not a static
#: file, so without this route the ``StaticFiles`` mount would 404 it.
shared_page_router = APIRouter(tags=["chats"])
#: The page's filename inside the static dir (the file lands in phase
#: 51, task 03; until then the guard below keeps a stale deploy from
#: 500'ing).
_SHARED_PAGE_NAME = "shared.html"
@shared_page_router.get("/shared/{token}", response_model=None)
def shared_page(token: uuid.UUID) -> FileResponse | JSONResponse:
"""Serve the anonymous shared-chat page for ``/shared/<token>``.
The page (``frontend/shared.html``, task 03) fetches
``GET /api/shared/<token>`` itself and renders the conversation
read-only — including the "invalid or revoked" state for a wrong
or revoked token — so this route serves the page for any well-
formed token and never 404s on the token's validity. The guard:
when the page file is missing (a stale deploy — the API is ahead
of the static bundle), return the SAME 404 JSON as the API rather
than a 500.
"""
page = Path(get_settings().static_dir).expanduser().resolve() / _SHARED_PAGE_NAME
if not page.is_file():
return JSONResponse(
status_code=404, content={"detail": "unknown or revoked share link"}
)
return FileResponse(page)