""" АНКОР · Живой класс — сервер. Запуск: pip install "fastapi>=0.110" "uvicorn[standard]" export ANKOR_PASSWORD="ваш-пароль-преподавателя" uvicorn server:app --host 0.0.0.0 --port 8000 Клиентский index.html положите в папку ./public рядом с этим файлом, тогда интерфейс и API будут на одном домене и API_BASE можно оставить пустым. Всё состояние хранится в памяти процесса: перезапуск стирает комнаты. Для одного преподавателя и группы участников этого достаточно; если нужен кластер из нескольких воркеров, запускайте строго один процесс (--workers 1), иначе комнаты разойдутся по воркерам. """ from __future__ import annotations import asyncio import os import re import secrets import time import uuid from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any from urllib.parse import quote, unquote, urlsplit from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException # -------------------------------------------------------------------------- # Настройки # -------------------------------------------------------------------------- TEACHER_PASSWORD = os.environ.get("ANKOR_PASSWORD", "") # Домены интерфейса, если он размещён отдельно от сервера. # Пример: ANKOR_ORIGINS="https://class.example.com,https://www.example.com" ALLOWED_ORIGINS = [ origin.strip() for origin in os.environ.get("ANKOR_ORIGINS", "").split(",") if origin.strip() ] ROOM_TTL = int(os.environ.get("ANKOR_ROOM_TTL", 12 * 3600)) # секунды MAX_FILE_BYTES = 8 * 1024 * 1024 MAX_ROOM_FILE_BYTES = 64 * 1024 * 1024 MAX_TASKS = 60 MAX_MEMBERS = 60 MAX_OBJECTS_PER_BOARD = 400 MAX_POINTS = 1500 MAX_ANSWER_CHARS = 15000 CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # без похожих символов CODE_LENGTH = 8 # помещается в maxlength="12" на клиенте OBJECT_TYPES = {"stroke", "text", "card", "rect", "circle"} TASK_KINDS = {"board", "image", "pdf", "text", "quiz", "sort", "link"} BOARD_KINDS = {"board", "image", "sort"} SHAREABLE_KINDS = BOARD_KINDS ALLOWED_MIME = { "image/png": "image", "image/jpeg": "image", "image/webp": "image", "application/pdf": "pdf", } COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") # -------------------------------------------------------------------------- # Ошибки: клиент показывает поле "error", поэтому формат единый везде # -------------------------------------------------------------------------- class ApiError(Exception): def __init__(self, status: int, message: str) -> None: self.status = status self.message = message class ConflictError(ApiError): """409: объект изменён кем-то другим. Клиент ждёт поле current.""" def __init__(self, message: str, current: dict[str, Any] | None) -> None: super().__init__(409, message) self.current = current # -------------------------------------------------------------------------- # Модель данных # -------------------------------------------------------------------------- @dataclass class Member: id: str name: str seen: float activity: str = "В комнате" @dataclass class Task: id: str kind: str title: str prompt: str mode: str config: dict[str, Any] @dataclass class BoardObject: id: str version: int deleted: bool data: dict[str, Any] op_id: str = "" def payload(self) -> dict[str, Any]: return { "id": self.id, "version": self.version, "deleted": self.deleted, "data": self.data, } @dataclass class StoredFile: id: str mime: str name: str body: bytes @dataclass class Room: code: str title: str created: float touched: float tasks: list[Task] = field(default_factory=list) active_task: str = "" members: dict[str, Member] = field(default_factory=dict) # answers[task_id][member_id] = {"value": str, "done": bool} answers: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict) # boards[(task_id, scope)][object_id] = BoardObject boards: dict[tuple[str, str], dict[str, BoardObject]] = field(default_factory=dict) files: dict[str, StoredFile] = field(default_factory=dict) file_bytes: int = 0 def task(self, task_id: str) -> Task | None: for item in self.tasks: if item.id == task_id: return item return None def current_task(self) -> Task: found = self.task(self.active_task) return found or self.tasks[0] @dataclass class Session: room: str member_id: str # "teacher" для преподавателя teacher: bool rooms: dict[str, Room] = {} sessions: dict[str, Session] = {} # -------------------------------------------------------------------------- # Вспомогательное # -------------------------------------------------------------------------- def now_ms() -> int: return int(time.time() * 1000) def new_code() -> str: while True: code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH)) if code not in rooms: return code def text_field(raw: Any, limit: int, label: str, required: bool = False) -> str: if raw is None: raw = "" if not isinstance(raw, str): raise ApiError(400, f"Поле «{label}» должно быть текстом.") value = raw.strip() if required and not value: raise ApiError(400, f"Заполните поле «{label}».") return value[:limit] def lines_field(raw: Any, limit: int, item_limit: int, label: str) -> list[str]: if not isinstance(raw, list): raise ApiError(400, f"Поле «{label}» заполнено неверно.") values = [ item.strip()[:item_limit] for item in raw if isinstance(item, str) and item.strip() ] if not values: raise ApiError(400, f"Заполните поле «{label}».") return values[:limit] def number(raw: Any, default: float, low: float, high: float) -> float: if isinstance(raw, bool) or not isinstance(raw, (int, float)): return default value = float(raw) if value != value or value in (float("inf"), float("-inf")): return default return max(low, min(high, value)) def clean_object_data(raw: Any) -> dict[str, Any]: """Приводим объект доски к безопасному виду, не доверяя клиенту.""" if not isinstance(raw, dict): raise ApiError(400, "Объект доски передан неверно.") kind = raw.get("type") if kind not in OBJECT_TYPES: raise ApiError(400, "Неизвестный тип объекта доски.") color = raw.get("color") if not isinstance(color, str) or not COLOR_RE.match(color): color = "#18324b" points: list[list[float]] = [] if kind == "stroke": source = raw.get("points") if not isinstance(source, list): raise ApiError(400, "Линия передана неверно.") for item in source[:MAX_POINTS]: if isinstance(item, list) and len(item) == 2: points.append( [ number(item[0], 0, 0, 1200), number(item[1], 0, 0, 750), ] ) if not points: raise ApiError(400, "Пустая линия.") text = raw.get("text") text = text[:500] if isinstance(text, str) else "" return { "type": kind, "x": number(raw.get("x"), 0, 0, 1200), "y": number(raw.get("y"), 0, 0, 750), "w": number(raw.get("w"), 20, 1, 1200), "h": number(raw.get("h"), 20, 1, 750), "text": text, "color": color, "width": number(raw.get("width"), 3, 1, 12), "points": points, } def external_url(raw: Any) -> str: value = text_field(raw, 2000, "Ссылка", required=True) parts = urlsplit(value) if parts.scheme != "https" or not parts.netloc: raise ApiError(400, "Нужна HTTPS-ссылка.") return value def resolve_scope(room: Room, task: Task, session: Session, requested: str) -> str: """ Общая доска — один ключ "shared" для всех. Индивидуальная — участник видит только своё, преподаватель выбирает чьё. """ if task.mode == "shared" and task.kind in SHAREABLE_KINDS: return "shared" if not session.teacher: return session.member_id if requested == "teacher" or requested in room.members: return requested return "teacher" def board_of(room: Room, task_id: str, scope: str) -> dict[str, BoardObject]: return room.boards.setdefault((task_id, scope), {}) def authorize(request: Request) -> tuple[Room, Session]: header = request.headers.get("authorization", "") if not header.lower().startswith("bearer "): raise ApiError(401, "Войдите в комнату заново.") session = sessions.get(header[7:].strip()) if not session: raise ApiError(401, "Сессия истекла. Войдите заново.") room = rooms.get(session.room) if not room: raise ApiError(404, "Комната закрыта или устарела.") room.touched = time.time() return room, session async def read_json(request: Request) -> dict[str, Any]: try: body = await request.json() except Exception: raise ApiError(400, "Неверный формат запроса.") if not isinstance(body, dict): raise ApiError(400, "Неверный формат запроса.") return body def check_password(body: dict[str, Any]) -> None: if not TEACHER_PASSWORD: raise ApiError(500, "На сервере не задан пароль преподавателя.") supplied = body.get("password") if not isinstance(supplied, str) or not secrets.compare_digest( supplied, TEACHER_PASSWORD ): raise ApiError(403, "Неверный пароль преподавателя.") def issue_token(room_code: str, member_id: str, teacher: bool) -> str: token = secrets.token_urlsafe(32) sessions[token] = Session(room=room_code, member_id=member_id, teacher=teacher) return token def sweep() -> None: deadline = time.time() - ROOM_TTL stale = [code for code, room in rooms.items() if room.touched < deadline] for code in stale: rooms.pop(code, None) if stale: dead = set(stale) for token in [t for t, s in sessions.items() if s.room in dead]: sessions.pop(token, None) # -------------------------------------------------------------------------- # Приложение # -------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): async def loop() -> None: while True: await asyncio.sleep(300) sweep() task = asyncio.create_task(loop()) try: yield finally: task.cancel() app = FastAPI(title="ANKOR live class", lifespan=lifespan) if ALLOWED_ORIGINS: app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "X-File-Name"], max_age=600, ) @app.exception_handler(ApiError) async def api_error_handler(request: Request, error: ApiError) -> JSONResponse: body: dict[str, Any] = {"error": error.message} if isinstance(error, ConflictError): body["current"] = error.current return JSONResponse(body, status_code=error.status) @app.exception_handler(StarletteHTTPException) async def http_error_handler( request: Request, error: StarletteHTTPException ) -> JSONResponse: detail = error.detail if isinstance(error.detail, str) else "Ошибка запроса." return JSONResponse({"error": detail}, status_code=error.status_code) @app.exception_handler(RequestValidationError) async def validation_error_handler( request: Request, error: RequestValidationError ) -> JSONResponse: return JSONResponse({"error": "Неверные данные запроса."}, status_code=400) @app.exception_handler(Exception) async def unexpected_error_handler(request: Request, error: Exception) -> JSONResponse: return JSONResponse({"error": "Внутренняя ошибка сервера."}, status_code=500) # -------------------------------------------------------------------------- # Вход в комнату # -------------------------------------------------------------------------- @app.post("/api/create") async def create(request: Request) -> dict[str, Any]: body = await read_json(request) check_password(body) code = new_code() moment = time.time() first = Task( id=uuid.uuid4().hex, kind="board", title="Общая доска", prompt="Пишите и рисуйте вместе.", mode="shared", config={}, ) room = Room( code=code, title=text_field(body.get("title"), 120, "Название") or "Совместное занятие", created=moment, touched=moment, tasks=[first], active_task=first.id, ) rooms[code] = room return {"room": code, "token": issue_token(code, "teacher", True), "teacher": True} @app.post("/api/resume") async def resume(request: Request) -> dict[str, Any]: body = await read_json(request) check_password(body) code = text_field(body.get("code"), 12, "Код комнаты", required=True).upper() room = rooms.get(code) if not room: raise ApiError(404, "Комната не найдена. Создайте новую.") room.touched = time.time() return {"room": code, "token": issue_token(code, "teacher", True), "teacher": True} @app.post("/api/join") async def join(request: Request) -> dict[str, Any]: body = await read_json(request) code = text_field(body.get("code"), 12, "Код комнаты", required=True).upper() name = text_field(body.get("name"), 50, "Имя", required=True) room = rooms.get(code) if not room: raise ApiError(404, "Комната не найдена. Проверьте код.") if len(room.members) >= MAX_MEMBERS: raise ApiError(409, "В комнате уже максимум участников.") member = Member(id=uuid.uuid4().hex, name=name, seen=time.time()) room.members[member.id] = member room.touched = time.time() return {"room": code, "token": issue_token(code, member.id, False), "teacher": False} # -------------------------------------------------------------------------- # Снимок состояния # -------------------------------------------------------------------------- @app.get("/api/state") async def state(request: Request) -> dict[str, Any]: room, session = authorize(request) requested_scope = request.query_params.get("scope", "teacher") activity = text_field(request.query_params.get("activity"), 60, "Активность") member = room.members.get(session.member_id) if member: member.seen = time.time() member.activity = activity or "В комнате" elif not session.teacher: raise ApiError(401, "Вы больше не в комнате. Присоединитесь заново.") task = room.current_task() scope = resolve_scope(room, task, session, requested_scope) task_answers = room.answers.get(task.id, {}) if session.teacher: answers = { member_id: dict(answer) for member_id, answer in task_answers.items() } else: own = task_answers.get(session.member_id) answers = {session.member_id: dict(own)} if own else {} snapshot: dict[str, Any] = { "time": now_ms(), "title": room.title, "code": room.code, "teacher": session.teacher, "me": { "id": session.member_id, "name": member.name if member else "Преподаватель", }, "scope": scope, "task": { "id": task.id, "kind": task.kind, "title": task.title, "prompt": task.prompt, "mode": task.mode, "config": task.config, }, "answers": answers, "objects": [ item.payload() for item in board_of(room, task.id, scope).values() ], } if session.teacher: snapshot["tasks"] = [ {"id": item.id, "kind": item.kind, "title": item.title} for item in room.tasks ] snapshot["members"] = [ { "id": item.id, "name": item.name, "seen": int(item.seen * 1000), "activity": item.activity, } for item in room.members.values() ] else: snapshot["tasks"] = [] snapshot["members"] = [] return snapshot # -------------------------------------------------------------------------- # Действия # -------------------------------------------------------------------------- @app.post("/api/action") async def action(request: Request) -> dict[str, Any]: room, session = authorize(request) body = await read_json(request) kind = body.get("action") if kind in ("object.put", "object.delete"): return object_action(room, session, body, delete=kind == "object.delete") if kind == "answer": return answer_action(room, session, body) if kind == "task.activate": return activate_action(room, session, body) if kind == "task.add": return add_task_action(room, session, body) raise ApiError(400, "Неизвестное действие.") def object_action( room: Room, session: Session, body: dict[str, Any], delete: bool ) -> dict[str, Any]: task_id = text_field(body.get("task"), 64, "Задание", required=True) task = room.task(task_id) if not task: raise ApiError(404, "Задание больше не существует.") if task.kind not in BOARD_KINDS: raise ApiError(400, "В этом задании нет доски.") scope = resolve_scope(room, task, session, str(body.get("scope") or "teacher")) if not session.teacher and str(body.get("scope")) != scope: raise ApiError(403, "Можно менять только свою доску.") object_id = text_field(body.get("id"), 64, "Объект", required=True) op_id = text_field(body.get("opId"), 64, "Операция") expected = int(number(body.get("version"), 0, 0, 10**9)) board = board_of(room, task_id, scope) existing = board.get(object_id) # Повтор той же операции после обрыва связи — отвечаем успехом. if existing and op_id and existing.op_id == op_id: return {"version": existing.version} current_version = existing.version if existing else 0 if expected != current_version: raise ConflictError( "Объект уже изменён другим участником.", existing.payload() if existing else None, ) if delete: if not existing: return {"version": 0} existing.version += 1 existing.deleted = True existing.op_id = op_id room.touched = time.time() return {"version": existing.version} data = clean_object_data(body.get("data")) if not existing: alive = sum(1 for item in board.values() if not item.deleted) if alive >= MAX_OBJECTS_PER_BOARD: raise ApiError(409, "На доске слишком много объектов.") existing = BoardObject(id=object_id, version=0, deleted=False, data=data) board[object_id] = existing existing.version += 1 existing.deleted = False existing.data = data existing.op_id = op_id room.touched = time.time() return {"version": existing.version} def answer_action( room: Room, session: Session, body: dict[str, Any] ) -> dict[str, Any]: if session.teacher: raise ApiError(403, "Преподаватель не отправляет ответы.") task_id = text_field(body.get("task"), 64, "Задание", required=True) task = room.task(task_id) if not task: raise ApiError(404, "Задание больше не существует.") value = body.get("value") value = value[:MAX_ANSWER_CHARS] if isinstance(value, str) else "" room.answers.setdefault(task_id, {})[session.member_id] = { "value": value, "done": bool(body.get("done")), "updated": now_ms(), } room.touched = time.time() return {"ok": True} def activate_action( room: Room, session: Session, body: dict[str, Any] ) -> dict[str, Any]: if not session.teacher: raise ApiError(403, "Только преподаватель меняет задание.") task_id = text_field(body.get("task"), 64, "Задание", required=True) if not room.task(task_id): raise ApiError(404, "Задание больше не существует.") room.active_task = task_id room.touched = time.time() return {"ok": True} def add_task_action( room: Room, session: Session, body: dict[str, Any] ) -> dict[str, Any]: if not session.teacher: raise ApiError(403, "Только преподаватель добавляет задания.") if len(room.tasks) >= MAX_TASKS: raise ApiError(409, "Слишком много заданий в комнате.") kind = body.get("kind") if kind not in TASK_KINDS: raise ApiError(400, "Неизвестный тип задания.") raw_config = body.get("config") if not isinstance(raw_config, dict): raw_config = {} config: dict[str, Any] = {} if kind == "link": config["url"] = external_url(raw_config.get("url")) if kind == "quiz": config["options"] = lines_field( raw_config.get("options"), 12, 200, "Варианты" ) if kind == "sort": config["labels"] = lines_field(raw_config.get("labels"), 30, 120, "Карточки") config["groups"] = lines_field(raw_config.get("groups"), 6, 60, "Группы") if kind in ("image", "pdf"): file_id = text_field(raw_config.get("fileId"), 64, "Файл", required=True) if file_id not in room.files: raise ApiError(404, "Файл не найден. Загрузите его заново.") config["fileId"] = file_id mode = body.get("mode") if kind in SHAREABLE_KINDS and mode == "shared": mode = "shared" else: mode = "individual" task = Task( id=uuid.uuid4().hex, kind=kind, title=text_field(body.get("title"), 150, "Название", required=True), prompt=text_field(body.get("prompt"), 5000, "Инструкция"), mode=mode, config=config, ) room.tasks.append(task) room.active_task = task.id room.touched = time.time() return {"ok": True, "task": task.id} # -------------------------------------------------------------------------- # Файлы # -------------------------------------------------------------------------- @app.post("/api/upload") async def upload(request: Request) -> dict[str, Any]: room, session = authorize(request) if not session.teacher: raise ApiError(403, "Файлы загружает преподаватель.") mime = (request.headers.get("content-type") or "").split(";")[0].strip().lower() if mime not in ALLOWED_MIME: raise ApiError(415, "Допустимы PNG, JPEG, WebP или PDF.") declared = request.headers.get("content-length") if declared and declared.isdigit() and int(declared) > MAX_FILE_BYTES: raise ApiError(413, "Файл больше 8 МБ.") chunks: list[bytes] = [] size = 0 async for chunk in request.stream(): size += len(chunk) if size > MAX_FILE_BYTES: raise ApiError(413, "Файл больше 8 МБ.") chunks.append(chunk) body = b"".join(chunks) if not body: raise ApiError(400, "Пустой файл.") if room.file_bytes + size > MAX_ROOM_FILE_BYTES: raise ApiError(413, "В комнате закончилось место для файлов.") try: name = unquote(request.headers.get("x-file-name", "") or "")[:120] except Exception: name = "" stored = StoredFile( id=uuid.uuid4().hex, mime=mime, name=name or "material", body=body ) room.files[stored.id] = stored room.file_bytes += size room.touched = time.time() return {"id": stored.id, "mime": stored.mime, "name": stored.name} @app.get("/api/file") async def file(request: Request) -> Response: room, _session = authorize(request) stored = room.files.get(request.query_params.get("id", "")) if not stored: raise ApiError(404, "Файл не найден.") return Response( content=stored.body, media_type=stored.mime, headers={ "Content-Disposition": ( "inline; filename*=UTF-8''" + quote(stored.name) ), "Cache-Control": "private, max-age=600", }, ) @app.get("/api/health") async def health() -> dict[str, Any]: return {"ok": True, "rooms": len(rooms), "time": now_ms()} # -------------------------------------------------------------------------- # Интерфейс (public/index.html) — монтируется последним # -------------------------------------------------------------------------- PUBLIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public") if os.path.isdir(PUBLIC_DIR): app.mount("/", StaticFiles(directory=PUBLIC_DIR, html=True), name="public")
Made on
Tilda