From 33607bae6e0c89fe3427950a1a4ccd7d16563c0a Mon Sep 17 00:00:00 2001 From: NikitaKu Date: Thu, 2 Jul 2026 20:28:02 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 10 ++ database.py | 312 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 280 ++++++++++++++++++++++++++++++++++++++++++++++ schemas.py | 50 +++++++++ security.py | 20 ++++ 5 files changed, 672 insertions(+) create mode 100644 config.py create mode 100644 database.py create mode 100644 main.py create mode 100644 schemas.py create mode 100644 security.py diff --git a/config.py b/config.py new file mode 100644 index 0000000..3ff542f --- /dev/null +++ b/config.py @@ -0,0 +1,10 @@ +import os +import secrets + +START_VIA_SCRIPT = os.getenv("ROBO_OPS_VIA_START_SH") == "1" + +SECRET_KEY = os.getenv("SECRET_KEY", secrets.token_hex(32)) +SESSION_COOKIE = "robo_ops_session" +SESSION_TTL_HOURS = 24 +EXTERNAL_API_KEY = os.getenv("EXTERNAL_API_KEY", "robo-ops-external-demo-key-2026") +DATABASE_PATH = os.getenv("DATABASE_PATH", "data/robo_ops.db") diff --git a/database.py b/database.py new file mode 100644 index 0000000..9cb1b2f --- /dev/null +++ b/database.py @@ -0,0 +1,312 @@ +import json +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.config import DATABASE_PATH + +ROBOTS_DEFAULT = [ + { + "id": "RB-001", "number": 1, "name": "Альфа-7", "type": "Промышленный манипулятор", + "status": "online", "battery": 87, "speed": 1.2, "maxSpeed": 2.4, "baseTemp": 32, + "payload": 15, "temperature": 42, "uptime": "142ч", "firmware": "v3.2.1", + "mode": "auto", "speedSetting": 50, "sensitivity": 70, "emergency": False, "nightMode": False, + }, + { + "id": "RB-002", "number": 2, "name": "Бета-3", "type": "Мобильный транспортёр", + "status": "idle", "battery": 64, "speed": 0.8, "maxSpeed": 1.6, "baseTemp": 30, + "payload": 50, "temperature": 38, "uptime": "89ч", "firmware": "v2.8.0", + "mode": "assist", "speedSetting": 35, "sensitivity": 55, "emergency": False, "nightMode": True, + }, + { + "id": "RB-003", "number": 3, "name": "Гамма-X", "type": "Сервисный ассистент", + "status": "online", "battery": 95, "speed": 0.5, "maxSpeed": 1.0, "baseTemp": 28, + "payload": 5, "temperature": 35, "uptime": "210ч", "firmware": "v4.0.2", + "mode": "manual", "speedSetting": 60, "sensitivity": 80, "emergency": False, "nightMode": False, + }, + { + "id": "RB-004", "number": 4, "name": "Дельта-9", "type": "Исследовательский дрон", + "status": "offline", "battery": 12, "speed": 0, "maxSpeed": 5.0, "baseTemp": 22, + "payload": 2, "temperature": 28, "uptime": "0ч", "firmware": "v1.9.5", + "mode": "standby", "speedSetting": 20, "sensitivity": 40, "emergency": True, "nightMode": False, + }, +] + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@contextmanager +def get_db(): + Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DATABASE_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + firstname TEXT DEFAULT '', + lastname TEXT DEFAULT '', + patronymic TEXT DEFAULT '', + age INTEGER, + phone TEXT DEFAULT '', + rating REAL NOT NULL DEFAULT 0, + points INTEGER NOT NULL DEFAULT 0, + selected_robot_id TEXT DEFAULT 'RB-001', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + user_agent TEXT DEFAULT '', + ip_address TEXT DEFAULT '', + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS robot_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + robot_id TEXT NOT NULL, + settings_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, robot_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS external_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + source_app TEXT NOT NULL, + data_key TEXT NOT NULL, + data_value TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_external_user ON external_data(user_id); + """) + + +def row_to_dict(row: sqlite3.Row) -> dict: + return dict(row) if row else None + + +def create_user(email: str, password_hash: str) -> dict: + now = utcnow().isoformat() + with get_db() as conn: + cur = conn.execute( + "INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)", + (email.lower().strip(), password_hash, now), + ) + user_id = cur.lastrowid + for robot in ROBOTS_DEFAULT: + conn.execute( + "INSERT INTO robot_settings (user_id, robot_id, settings_json, updated_at) VALUES (?, ?, ?, ?)", + (user_id, robot["id"], json.dumps(robot, ensure_ascii=False), now), + ) + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return row_to_dict(row) + + +def get_user_by_email(email: str) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE email = ? COLLATE NOCASE", + (email.lower().strip(),), + ).fetchone() + return row_to_dict(row) + + +def get_user_by_id(user_id: int) -> dict | None: + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return row_to_dict(row) + + +def update_user_profile(user_id: int, data: dict) -> dict: + with get_db() as conn: + conn.execute( + """UPDATE users SET firstname=?, lastname=?, patronymic=?, age=?, phone=? + WHERE id=?""", + ( + data.get("firstname", ""), + data.get("lastname", ""), + data.get("patronymic", ""), + data.get("age"), + data.get("phone", ""), + user_id, + ), + ) + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return row_to_dict(row) + + +def create_session(user_id: int, token_hash: str, user_agent: str, ip_address: str, ttl_hours: int) -> dict: + now = utcnow() + expires = now + timedelta(hours=ttl_hours) + with get_db() as conn: + conn.execute( + """INSERT INTO sessions (user_id, token_hash, created_at, expires_at, user_agent, ip_address) + VALUES (?, ?, ?, ?, ?, ?)""", + (user_id, token_hash, now.isoformat(), expires.isoformat(), user_agent, ip_address), + ) + row = conn.execute( + "SELECT * FROM sessions WHERE token_hash = ?", (token_hash,) + ).fetchone() + return row_to_dict(row) + + +def get_session_by_token_hash(token_hash: str) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM sessions WHERE token_hash = ? AND is_active = 1", + (token_hash,), + ).fetchone() + return row_to_dict(row) + + +def deactivate_session(token_hash: str) -> None: + with get_db() as conn: + conn.execute( + "UPDATE sessions SET is_active = 0 WHERE token_hash = ?", + (token_hash,), + ) + + +def get_active_sessions(user_id: int | None = None) -> list[dict]: + now = utcnow().isoformat() + with get_db() as conn: + if user_id is not None: + rows = conn.execute( + """SELECT s.*, u.email FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.is_active = 1 AND s.expires_at > ? AND s.user_id = ? + ORDER BY s.created_at DESC""", + (now, user_id), + ).fetchall() + else: + rows = conn.execute( + """SELECT s.*, u.email FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.is_active = 1 AND s.expires_at > ? + ORDER BY s.created_at DESC""", + (now,), + ).fetchall() + return [row_to_dict(r) for r in rows] + + +def get_user_robots(user_id: int) -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT robot_id, settings_json FROM robot_settings WHERE user_id = ? ORDER BY robot_id", + (user_id,), + ).fetchall() + if not rows: + now = utcnow().isoformat() + for robot in ROBOTS_DEFAULT: + conn.execute( + "INSERT INTO robot_settings (user_id, robot_id, settings_json, updated_at) VALUES (?, ?, ?, ?)", + (user_id, robot["id"], json.dumps(robot, ensure_ascii=False), now), + ) + rows = conn.execute( + "SELECT robot_id, settings_json FROM robot_settings WHERE user_id = ? ORDER BY robot_id", + (user_id,), + ).fetchall() + return [json.loads(r["settings_json"]) for r in rows] + + +def update_robot_settings(user_id: int, robot_id: str, settings: dict) -> dict: + now = utcnow().isoformat() + with get_db() as conn: + conn.execute( + """INSERT INTO robot_settings (user_id, robot_id, settings_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, robot_id) DO UPDATE SET + settings_json = excluded.settings_json, updated_at = excluded.updated_at""", + (user_id, robot_id, json.dumps(settings, ensure_ascii=False), now), + ) + return settings + + +def set_selected_robot(user_id: int, robot_id: str) -> None: + with get_db() as conn: + conn.execute( + "UPDATE users SET selected_robot_id = ? WHERE id = ?", + (robot_id, user_id), + ) + + +def add_external_data(user_id: int | None, source_app: str, data_key: str, data_value: dict) -> dict: + now = utcnow().isoformat() + with get_db() as conn: + cur = conn.execute( + """INSERT INTO external_data (user_id, source_app, data_key, data_value, created_at) + VALUES (?, ?, ?, ?, ?)""", + (user_id, source_app, data_key, json.dumps(data_value, ensure_ascii=False), now), + ) + if user_id and "points" in data_value: + points = int(data_value["points"]) + conn.execute( + "UPDATE users SET points = points + ? WHERE id = ?", + (points, user_id), + ) + if user_id and "rating" in data_value: + conn.execute( + "UPDATE users SET rating = ? WHERE id = ?", + (float(data_value["rating"]), user_id), + ) + row = conn.execute( + "SELECT * FROM external_data WHERE id = ?", (cur.lastrowid,) + ).fetchone() + return row_to_dict(row) + + +def get_external_data(user_id: int | None = None, limit: int = 100) -> list[dict]: + with get_db() as conn: + if user_id is not None: + rows = conn.execute( + "SELECT * FROM external_data WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", + (user_id, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM external_data ORDER BY created_at DESC LIMIT ?", + (limit,), + ).fetchall() + result = [] + for r in rows: + item = row_to_dict(r) + item["data_value"] = json.loads(item["data_value"]) + result.append(item) + return result + + +def list_users_public() -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT id, email, firstname, lastname, rating, points, created_at FROM users ORDER BY id" + ).fetchall() + return [row_to_dict(r) for r in rows] diff --git a/main.py b/main.py new file mode 100644 index 0000000..6376236 --- /dev/null +++ b/main.py @@ -0,0 +1,280 @@ +from datetime import datetime, timezone + +from fastapi import Cookie, Depends, FastAPI, HTTPException, Request, Response +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from app import database as db +from app.config import EXTERNAL_API_KEY, SESSION_COOKIE, SESSION_TTL_HOURS, START_VIA_SCRIPT +from app.database import init_db +from app.schemas import ( + ExternalDataWrite, + LoginRequest, + ProfileUpdate, + RegisterRequest, + RobotSettingsUpdate, + RobotsBulkUpdate, +) +from app.security import ( + generate_session_token, + hash_password, + hash_token, + verify_password, +) + +app = FastAPI(title="ROBO-OPS", version="2.0.0") + +STATIC_DIR = __import__("pathlib").Path(__file__).resolve().parent.parent / "static" + + +@app.on_event("startup") +def on_startup(): + if not START_VIA_SCRIPT: + raise RuntimeError( + "Приложение запускается только через ./start.sh\n" + "См. README.md — раздел «Запуск приложения»." + ) + init_db() + + +def user_public(user: dict) -> dict: + return { + "id": user["id"], + "email": user["email"], + "firstname": user["firstname"], + "lastname": user["lastname"], + "patronymic": user["patronymic"], + "age": user["age"], + "phone": user["phone"], + "rating": user["rating"], + "points": user["points"], + "selected_robot_id": user["selected_robot_id"], + } + + +def session_public(session: dict) -> dict: + return { + "id": session["id"], + "user_id": session["user_id"], + "email": session.get("email", ""), + "created_at": session["created_at"], + "expires_at": session["expires_at"], + "user_agent": session["user_agent"], + "ip_address": session["ip_address"], + } + + +async def get_current_user( + request: Request, + session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE), +) -> dict: + if not session_token: + raise HTTPException(status_code=401, detail="Требуется авторизация") + token_hash = hash_token(session_token) + session = db.get_session_by_token_hash(token_hash) + if not session: + raise HTTPException(status_code=401, detail="Сессия недействительна") + expires = datetime.fromisoformat(session["expires_at"]) + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + if expires < datetime.now(timezone.utc): + db.deactivate_session(token_hash) + raise HTTPException(status_code=401, detail="Сессия истекла") + user = db.get_user_by_id(session["user_id"]) + if not user: + raise HTTPException(status_code=401, detail="Пользователь не найден") + return user + + +def verify_external_api_key(request: Request) -> None: + api_key = request.headers.get("X-API-Key") + if not api_key or api_key != EXTERNAL_API_KEY: + raise HTTPException(status_code=403, detail="Неверный API-ключ") + + +# --- Auth --- + +@app.post("/api/auth/register") +def register(data: RegisterRequest, response: Response, request: Request): + if db.get_user_by_email(data.email): + raise HTTPException(status_code=400, detail="Email уже зарегистрирован") + user = db.create_user(data.email, hash_password(data.password)) + token = generate_session_token() + db.create_session( + user["id"], + hash_token(token), + request.headers.get("user-agent", ""), + request.client.host if request.client else "", + SESSION_TTL_HOURS, + ) + response.set_cookie( + key=SESSION_COOKIE, + value=token, + httponly=True, + samesite="lax", + max_age=SESSION_TTL_HOURS * 3600, + ) + return {"user": user_public(user), "message": "Регистрация успешна"} + + +@app.post("/api/auth/login") +def login(data: LoginRequest, response: Response, request: Request): + user = db.get_user_by_email(data.email) + if not user or not verify_password(data.password, user["password_hash"]): + raise HTTPException(status_code=401, detail="Неверный email или пароль") + token = generate_session_token() + db.create_session( + user["id"], + hash_token(token), + request.headers.get("user-agent", ""), + request.client.host if request.client else "", + SESSION_TTL_HOURS, + ) + response.set_cookie( + key=SESSION_COOKIE, + value=token, + httponly=True, + samesite="lax", + max_age=SESSION_TTL_HOURS * 3600, + ) + return {"user": user_public(user), "message": "Вход выполнен"} + + +@app.post("/api/auth/logout") +def logout( + response: Response, + session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE), +): + if session_token: + db.deactivate_session(hash_token(session_token)) + response.delete_cookie(SESSION_COOKIE) + return {"message": "Выход выполнен"} + + +@app.get("/api/auth/me") +def me(user: dict = Depends(get_current_user)): + return user_public(user) + + +# --- Profile --- + +@app.get("/api/profile") +def get_profile(user: dict = Depends(get_current_user)): + return user_public(user) + + +@app.put("/api/profile") +def update_profile(data: ProfileUpdate, user: dict = Depends(get_current_user)): + updated = db.update_user_profile(user["id"], data.model_dump()) + return user_public(updated) + + +# --- Sessions --- + +@app.get("/api/sessions") +def my_sessions(user: dict = Depends(get_current_user)): + sessions = db.get_active_sessions(user["id"]) + return {"sessions": [session_public(s) for s in sessions]} + + +# --- Robots --- + +@app.get("/api/robots") +def get_robots(user: dict = Depends(get_current_user)): + robots = db.get_user_robots(user["id"]) + return { + "robots": robots, + "selected_robot_id": user["selected_robot_id"], + } + + +@app.put("/api/robots") +def save_robots(data: RobotsBulkUpdate, user: dict = Depends(get_current_user)): + for robot in data.robots: + robot_id = robot.get("id") + if robot_id: + db.update_robot_settings(user["id"], robot_id, robot) + if data.selected_robot_id: + db.set_selected_robot(user["id"], data.selected_robot_id) + user = db.get_user_by_id(user["id"]) + return { + "robots": db.get_user_robots(user["id"]), + "selected_robot_id": user["selected_robot_id"], + } + + +@app.put("/api/robots/{robot_id}") +def save_robot(robot_id: str, data: RobotSettingsUpdate, user: dict = Depends(get_current_user)): + settings = {**data.settings, "id": robot_id} + db.update_robot_settings(user["id"], robot_id, settings) + return settings + + +# --- External API --- + +@app.get("/api/external/users") +def external_list_users(_: None = Depends(verify_external_api_key)): + return {"users": db.list_users_public()} + + +@app.get("/api/external/users/{user_id}") +def external_get_user(user_id: int, _: None = Depends(verify_external_api_key)): + user = db.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + return user_public(user) + + +@app.get("/api/external/users/by-email/{email}") +def external_get_user_by_email(email: str, _: None = Depends(verify_external_api_key)): + user = db.get_user_by_email(email) + if not user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + return user_public(user) + + +@app.get("/api/external/sessions") +def external_sessions( + user_id: int | None = None, + _: None = Depends(verify_external_api_key), +): + sessions = db.get_active_sessions(user_id) + return {"sessions": [session_public(s) for s in sessions]} + + +@app.post("/api/external/data") +def external_write_data(data: ExternalDataWrite, _: None = Depends(verify_external_api_key)): + if data.user_id is not None and not db.get_user_by_id(data.user_id): + raise HTTPException(status_code=404, detail="Пользователь не найден") + record = db.add_external_data(data.user_id, data.source_app, data.data_key, data.data_value) + record["data_value"] = __import__("json").loads(record["data_value"]) + return {"record": record, "message": "Данные записаны"} + + +@app.get("/api/external/data") +def external_get_data( + user_id: int | None = None, + limit: int = 100, + _: None = Depends(verify_external_api_key), +): + return {"data": db.get_external_data(user_id, min(limit, 500))} + + +# --- Static pages --- + +@app.get("/") +def index(): + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/login") +def login_page(): + return FileResponse(STATIC_DIR / "login.html") + + +@app.get("/register") +def register_page(): + return FileResponse(STATIC_DIR / "register.html") + + +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..d32d2c1 --- /dev/null +++ b/schemas.py @@ -0,0 +1,50 @@ +from typing import Any + +from pydantic import BaseModel, EmailStr, Field + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=6, max_length=128) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class ProfileUpdate(BaseModel): + firstname: str = "" + lastname: str = "" + patronymic: str = "" + age: int | None = Field(default=None, ge=18, le=120) + phone: str = "" + + +class RobotSettingsUpdate(BaseModel): + settings: dict[str, Any] + + +class RobotsBulkUpdate(BaseModel): + robots: list[dict[str, Any]] + selected_robot_id: str | None = None + + +class ExternalDataWrite(BaseModel): + user_id: int | None = None + source_app: str = Field(min_length=1, max_length=100) + data_key: str = Field(min_length=1, max_length=100) + data_value: dict[str, Any] + + +class UserPublic(BaseModel): + id: int + email: str + firstname: str + lastname: str + patronymic: str + age: int | None + phone: str + rating: float + points: int + selected_robot_id: str diff --git a/security.py b/security.py new file mode 100644 index 0000000..d58911e --- /dev/null +++ b/security.py @@ -0,0 +1,20 @@ +import hashlib +import secrets + +import bcrypt + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +def generate_session_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest()