feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
+179
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user