Add random 4-digit TV token per child for obfuscated TV URLs

Each child is assigned a unique permanent tv_token on creation. The TV
dashboard URL (/tv/:tvToken) and WebSocket (/ws/:tvToken) now use this
token instead of the internal DB ID. Existing children are backfilled
on startup. README updated to reflect the change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 22:53:26 -07:00
parent 4bd9218bf5
commit 68a5e9cb4f
7 changed files with 52 additions and 12 deletions

View File

@@ -181,7 +181,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
5. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here. 5. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here.
6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes. 6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes.
7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template 7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
8. **TV**Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID) 8. **TV**From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV.
--- ---
@@ -236,9 +236,9 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay | | `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay |
Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
### API Documentation ### API Documentation

View File

@@ -1,3 +1,4 @@
import random
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings from app.config import get_settings
from app.database import engine from app.database import engine
from app.models import Base from app.models import Base
from app.models.child import Child
from app.models.subject import Subject from app.models.subject import Subject
from app.models.user import User from app.models.user import User
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
@@ -41,9 +43,27 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0") await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL") await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL")
await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL") await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL")
await _add_column_if_missing(conn, "children", "tv_token", "INT NULL")
# Backfill tv_token for existing children that don't have one
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
result = await db.execute(select(Child).where(Child.tv_token == None)) # noqa: E711
children_without_token = result.scalars().all()
used_tokens = set()
for child in children_without_token:
while True:
token = random.randint(1000, 9999)
if token not in used_tokens:
existing = await db.execute(select(Child).where(Child.tv_token == token))
if not existing.scalar_one_or_none():
break
child.tv_token = token
used_tokens.add(token)
if children_without_token:
await db.commit()
# Seed Meeting system subject for any existing users who don't have one # Seed Meeting system subject for any existing users who don't have one
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
users_result = await db.execute(select(User).where(User.is_active == True)) users_result = await db.execute(select(User).where(User.is_active == True))
all_users = users_result.scalars().all() all_users = users_result.scalars().all()
@@ -100,12 +120,19 @@ async def health():
return {"status": "ok"} return {"status": "ok"}
@app.websocket("/ws/{child_id}") @app.websocket("/ws/{tv_token}")
async def websocket_endpoint(websocket: WebSocket, child_id: int): async def websocket_endpoint(websocket: WebSocket, tv_token: int):
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
result = await db.execute(select(Child).where(Child.tv_token == tv_token))
child = result.scalar_one_or_none()
if not child:
await websocket.close(code=4004)
return
child_id = child.id
await manager.connect(websocket, child_id) await manager.connect(websocket, child_id)
try: try:
while True: while True:
# Keep connection alive; TV clients are receive-only
await websocket.receive_text() await websocket.receive_text()
except WebSocketDisconnect: except WebSocketDisconnect:
manager.disconnect(websocket, child_id) manager.disconnect(websocket, child_id)

View File

@@ -16,6 +16,7 @@ class Child(TimestampMixin, Base):
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False) strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
strikes_last_reset: Mapped[Optional[date]] = mapped_column(Date, nullable=True, default=None) strikes_last_reset: Mapped[Optional[date]] = mapped_column(Date, nullable=True, default=None)
tv_token: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, unique=True)
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821 user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821 daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821

View File

@@ -1,3 +1,4 @@
import random
from datetime import datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -49,13 +50,22 @@ async def list_children(
return children return children
async def _generate_tv_token(db: AsyncSession) -> int:
while True:
token = random.randint(1000, 9999)
result = await db.execute(select(Child).where(Child.tv_token == token))
if not result.scalar_one_or_none():
return token
@router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED)
async def create_child( async def create_child(
body: ChildCreate, body: ChildCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
child = Child(**body.model_dump(), user_id=current_user.id) tv_token = await _generate_tv_token(db)
child = Child(**body.model_dump(), user_id=current_user.id, tv_token=tv_token)
db.add(child) db.add(child)
await db.commit() await db.commit()
await db.refresh(child) await db.refresh(child)

View File

@@ -22,12 +22,13 @@ from app.utils.timer import compute_block_elapsed, compute_break_elapsed
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/{child_id}", response_model=DashboardSnapshot) @router.get("/{tv_token}", response_model=DashboardSnapshot)
async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
child_result = await db.execute(select(Child).where(Child.id == child_id, Child.is_active == True)) child_result = await db.execute(select(Child).where(Child.tv_token == tv_token, Child.is_active == True))
child = child_result.scalar_one_or_none() child = child_result.scalar_one_or_none()
if not child: if not child:
raise HTTPException(status_code=404, detail="Child not found") raise HTTPException(status_code=404, detail="Child not found")
child_id = child.id
# Get today's active session # Get today's active session
session_result = await db.execute( session_result = await db.execute(

View File

@@ -23,5 +23,6 @@ class ChildOut(BaseModel):
is_active: bool is_active: bool
color: str color: str
strikes: int = 0 strikes: int = 0
tv_token: int | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -38,7 +38,7 @@
<div class="card tv-card"> <div class="card tv-card">
<div class="card-title">TV Dashboard</div> <div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p> <p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.id}`" target="_blank" class="btn-primary"> <a :href="`/tv/${activeChild.tv_token}`" target="_blank" class="btn-primary">
Open TV View Open TV View
</a> </a>
</div> </div>