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

@@ -1,3 +1,4 @@
import random
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import engine
from app.models import Base
from app.models.child import Child
from app.models.subject import Subject
from app.models.user import User
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, "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", "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
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
users_result = await db.execute(select(User).where(User.is_active == True))
all_users = users_result.scalars().all()
@@ -100,12 +120,19 @@ async def health():
return {"status": "ok"}
@app.websocket("/ws/{child_id}")
async def websocket_endpoint(websocket: WebSocket, child_id: int):
@app.websocket("/ws/{tv_token}")
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)
try:
while True:
# Keep connection alive; TV clients are receive-only
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket, child_id)