diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/app/__init__.py differ diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..9e70c25 --- /dev/null +++ b/app/database.py @@ -0,0 +1,82 @@ +import sqlite3 +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DB_PATH = os.path.join(BASE_DIR, "database.db") + + +def get_connection(): + """Открывает соединение с БД.""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row # доступ к колонкам по имени + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(): + """Создаёт таблицы при первом запуске.""" + conn = get_connection() + cur = conn.cursor() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + """) + + # status — текстовое состояние ("on"/"off"/"error" и т.д.) + # value — произвольный числовой параметр объекта + cur.execute(""" + CREATE TABLE IF NOT EXISTS objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'off', + value REAL NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE + ) + """) + + # source: 'user' — команда из кабинета, 'external' — данные от внешней системы + cur.execute(""" + CREATE TABLE IF NOT EXISTS command_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + object_id INTEGER NOT NULL, + source TEXT NOT NULL, + status TEXT NOT NULL, + value REAL NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (object_id) REFERENCES objects (id) ON DELETE CASCADE + ) + """) + + # миграция: поля профиля пилота (для существующих БД) + cur.execute("PRAGMA table_info(users)") + existing_cols = {row[1] for row in cur.fetchall()} + profile_cols = { + "full_name": "TEXT", + "birth_date": "TEXT", + "phone": "TEXT", + "gender": "TEXT", + } + for col, col_type in profile_cols.items(): + if col not in existing_cols: + cur.execute(f"ALTER TABLE users ADD COLUMN {col} {col_type}") + + conn.commit() + conn.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f5dc4b4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,423 @@ +import os +import re +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import FastAPI, Depends, HTTPException, Header, Cookie, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import RedirectResponse +from pydantic import BaseModel + +from app.database import init_db, get_connection +from app.security import hash_password, verify_password, generate_token + +# --- настройки --- + +# ключ для внешних приложений, можно задать через переменную окружения +EXTERNAL_API_KEY = os.environ.get("EXTERNAL_API_KEY", "super-secret-external-key") + +SESSION_LIFETIME_DAYS = 7 +EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +HISTORY_LIMIT = 50 # кол-во записей журнала + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATIC_DIR = os.path.join(BASE_DIR, "static") + +# --- приложение --- + +app = FastAPI(title="Robo Arena — личный кабинет удалённой системы управления") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +init_db() + + +@app.get("/") +def root(): + return RedirectResponse(url="/static/index.html") + + +# --- модели запросов --- + +class RegisterRequest(BaseModel): + email: str + password: str + + +class LoginRequest(BaseModel): + email: str + password: str + + +class ObjectCreateRequest(BaseModel): + name: str + + +class CommandRequest(BaseModel): + status: str # "on" / "off" / "error" и т.д. + value: float = 0 + + +class TelemetryRequest(BaseModel): + status: str + value: float = 0 + + +class ProfileUpdateRequest(BaseModel): + full_name: str + birth_date: str + phone: str + gender: str + + +# --- зависимости --- + +def get_current_user(session_token: Optional[str] = Cookie(default=None)): + """Возвращает пользователя по cookie-сессии.""" + if not session_token: + raise HTTPException(status_code=401, detail="Не авторизован") + + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM sessions WHERE token = ?", (session_token,)) + session = cur.fetchone() + + if session is None: + conn.close() + raise HTTPException(status_code=401, detail="Сессия не найдена") + + if datetime.fromisoformat(session["expires_at"]) < datetime.utcnow(): + cur.execute("DELETE FROM sessions WHERE token = ?", (session_token,)) + conn.commit() + conn.close() + raise HTTPException(status_code=401, detail="Сессия истекла") + + cur.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)) + user = cur.fetchone() + conn.close() + + if user is None: + raise HTTPException(status_code=401, detail="Пользователь не найден") + + return user + + +def check_api_key(x_api_key: Optional[str] = Header(default=None)): + """Проверяет X-API-Key внешнего приложения.""" + if not x_api_key or x_api_key != EXTERNAL_API_KEY: + raise HTTPException(status_code=403, detail="Неверный API-ключ") + return True + + +def get_owned_object(conn, object_id: int, user_id: int): + """Возвращает объект, если он принадлежит пользователю.""" + cur = conn.cursor() + cur.execute("SELECT * FROM objects WHERE id = ? AND owner_id = ?", (object_id, user_id)) + obj = cur.fetchone() + if obj is None: + raise HTTPException(status_code=404, detail="Объект не найден") + return obj + + +# --- регистрация / вход / выход --- + +@app.post("/api/register") +def register(data: RegisterRequest, response: Response): + email = data.email.strip().lower() + + if not EMAIL_REGEX.match(email): + raise HTTPException(status_code=400, detail="Некорректный формат почты") + + if len(data.password) < 6: + raise HTTPException(status_code=400, detail="Пароль должен быть не короче 6 символов") + + conn = get_connection() + cur = conn.cursor() + + cur.execute("SELECT id FROM users WHERE email = ?", (email,)) + if cur.fetchone() is not None: + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с такой почтой уже существует") + + password_hash, salt = hash_password(data.password) + cur.execute("INSERT INTO users (email, password_hash, salt) VALUES (?, ?, ?)", + (email, password_hash, salt)) + conn.commit() + user_id = cur.lastrowid + + # сразу создаём сессию после регистрации + token = generate_token() + expires_at = datetime.utcnow() + timedelta(days=SESSION_LIFETIME_DAYS) + cur.execute("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + (token, user_id, expires_at.isoformat())) + conn.commit() + conn.close() + + response.set_cookie(key="session_token", value=token, httponly=True, + max_age=SESSION_LIFETIME_DAYS * 24 * 60 * 60, samesite="lax") + return {"message": "Регистрация прошла успешно"} + + +@app.post("/api/login") +def login(data: LoginRequest, response: Response): + email = data.email.strip().lower() + + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM users WHERE email = ?", (email,)) + user = cur.fetchone() + + if user is None or not verify_password(data.password, user["salt"], user["password_hash"]): + conn.close() + raise HTTPException(status_code=401, detail="Неверная почта или пароль") + + token = generate_token() + expires_at = datetime.utcnow() + timedelta(days=SESSION_LIFETIME_DAYS) + cur.execute("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + (token, user["id"], expires_at.isoformat())) + conn.commit() + conn.close() + + response.set_cookie(key="session_token", value=token, httponly=True, + max_age=SESSION_LIFETIME_DAYS * 24 * 60 * 60, samesite="lax") + return {"message": "Вход выполнен успешно"} + + +@app.post("/api/logout") +def logout(response: Response, session_token: Optional[str] = Cookie(default=None)): + if session_token: + conn = get_connection() + cur = conn.cursor() + cur.execute("DELETE FROM sessions WHERE token = ?", (session_token,)) + conn.commit() + conn.close() + + response.delete_cookie("session_token") + return {"message": "Вы вышли из системы"} + + +def _user_profile(user) -> dict: + return { + "id": user["id"], + "email": user["email"], + "created_at": user["created_at"], + "full_name": user["full_name"] or "", + "birth_date": user["birth_date"] or "", + "phone": user["phone"] or "", + "gender": user["gender"] or "", + "profile_complete": bool( + user["full_name"] and user["birth_date"] and user["phone"] and user["gender"] + ), + } + + +@app.get("/api/me") +def get_me(user=Depends(get_current_user)): + return _user_profile(user) + + +@app.put("/api/me/profile") +def update_profile(data: ProfileUpdateRequest, user=Depends(get_current_user)): + full_name = data.full_name.strip() + birth_date = data.birth_date.strip() + phone = data.phone.strip() + gender = data.gender.strip().lower() + + if not full_name or len(full_name) < 3: + raise HTTPException(status_code=400, detail="Укажите ФИО (не менее 3 символов)") + + if not birth_date: + raise HTTPException(status_code=400, detail="Укажите дату рождения") + + try: + datetime.fromisoformat(birth_date) + except ValueError: + raise HTTPException(status_code=400, detail="Некорректная дата рождения") + + if not phone or len(phone) < 7: + raise HTTPException(status_code=400, detail="Укажите корректный номер телефона") + + allowed_genders = {"male", "female", "other"} + if gender not in allowed_genders: + raise HTTPException(status_code=400, detail="Выберите пол из списка") + + conn = get_connection() + cur = conn.cursor() + cur.execute( + "UPDATE users SET full_name = ?, birth_date = ?, phone = ?, gender = ? WHERE id = ?", + (full_name, birth_date, phone, gender, user["id"]), + ) + conn.commit() + cur.execute("SELECT * FROM users WHERE id = ?", (user["id"],)) + updated = cur.fetchone() + conn.close() + return _user_profile(updated) + + +# --- объекты пользователя --- + +@app.get("/api/objects") +def list_objects(user=Depends(get_current_user)): + """Все объекты текущего пользователя.""" + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT * FROM objects WHERE owner_id = ? ORDER BY id", (user["id"],)) + objects = [dict(row) for row in cur.fetchall()] + conn.close() + return {"objects": objects} + + +@app.post("/api/objects") +def create_object(data: ObjectCreateRequest, user=Depends(get_current_user)): + """Создаёт новый объект.""" + name = data.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Название не может быть пустым") + + conn = get_connection() + cur = conn.cursor() + cur.execute("INSERT INTO objects (owner_id, name, status, value) VALUES (?, ?, 'off', 0)", + (user["id"], name)) + conn.commit() + cur.execute("SELECT * FROM objects WHERE id = ?", (cur.lastrowid,)) + obj = dict(cur.fetchone()) + conn.close() + return obj + + +@app.delete("/api/objects/{object_id}") +def delete_object(object_id: int, user=Depends(get_current_user)): + """Удаляет объект пользователя.""" + conn = get_connection() + get_owned_object(conn, object_id, user["id"]) # проверка владения + + cur = conn.cursor() + cur.execute("DELETE FROM objects WHERE id = ?", (object_id,)) + conn.commit() + conn.close() + return {"message": "Объект удалён"} + + +@app.post("/api/objects/{object_id}/command") +def send_command(object_id: int, data: CommandRequest, user=Depends(get_current_user)): + """Обновляет статус и значение, пишет запись в журнал.""" + conn = get_connection() + get_owned_object(conn, object_id, user["id"]) + + now = datetime.utcnow().isoformat() + cur = conn.cursor() + cur.execute("UPDATE objects SET status = ?, value = ?, updated_at = ? WHERE id = ?", + (data.status, data.value, now, object_id)) + cur.execute("INSERT INTO command_log (object_id, source, status, value) VALUES (?, 'user', ?, ?)", + (object_id, data.status, data.value)) + conn.commit() + + cur.execute("SELECT * FROM objects WHERE id = ?", (object_id,)) + obj = dict(cur.fetchone()) + conn.close() + return obj + + +@app.get("/api/objects/{object_id}/history") +def object_history(object_id: int, user=Depends(get_current_user)): + """Последние записи журнала по объекту.""" + conn = get_connection() + get_owned_object(conn, object_id, user["id"]) + + cur = conn.cursor() + cur.execute( + "SELECT source, status, value, created_at FROM command_log " + "WHERE object_id = ? ORDER BY id DESC LIMIT ?", + (object_id, HISTORY_LIMIT), + ) + history = [dict(row) for row in cur.fetchall()] + conn.close() + return {"history": history} + + +# --- внешний API (требует X-API-Key) --- + +@app.get("/api/external/users") +def external_get_users(_=Depends(check_api_key)): + """Все пользователи системы.""" + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT id, email, created_at FROM users") + users = [dict(row) for row in cur.fetchall()] + conn.close() + return {"users": users} + + +@app.get("/api/external/users/{email}") +def external_get_user(email: str, _=Depends(check_api_key)): + """Пользователь по email.""" + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT id, email, created_at FROM users WHERE email = ?", (email.strip().lower(),)) + user = cur.fetchone() + conn.close() + + if user is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + return dict(user) + + +@app.get("/api/external/sessions") +def external_get_sessions(_=Depends(check_api_key)): + """Активные (не истёкшие) сессии.""" + conn = get_connection() + cur = conn.cursor() + cur.execute( + "SELECT s.user_id, u.email, s.created_at, s.expires_at " + "FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.expires_at > ?", + (datetime.utcnow().isoformat(),), + ) + sessions = [dict(row) for row in cur.fetchall()] + conn.close() + return {"active_sessions": sessions} + + +@app.get("/api/external/objects") +def external_get_objects(_=Depends(check_api_key)): + """Все объекты со статусами и владельцами.""" + conn = get_connection() + cur = conn.cursor() + cur.execute( + "SELECT o.id, o.name, o.status, o.value, o.updated_at, u.email AS owner_email " + "FROM objects o JOIN users u ON u.id = o.owner_id" + ) + objects = [dict(row) for row in cur.fetchall()] + conn.close() + return {"objects": objects} + + +@app.post("/api/external/objects/{object_id}/telemetry") +def external_write_telemetry(object_id: int, data: TelemetryRequest, _=Depends(check_api_key)): + """Запись телеметрии от внешней системы (source='external').""" + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT id FROM objects WHERE id = ?", (object_id,)) + if cur.fetchone() is None: + conn.close() + raise HTTPException(status_code=404, detail="Объект не найден") + + now = datetime.utcnow().isoformat() + cur.execute("UPDATE objects SET status = ?, value = ?, updated_at = ? WHERE id = ?", + (data.status, data.value, now, object_id)) + cur.execute("INSERT INTO command_log (object_id, source, status, value) VALUES (?, 'external', ?, ?)", + (object_id, data.status, data.value)) + conn.commit() + + cur.execute("SELECT * FROM objects WHERE id = ?", (object_id,)) + obj = dict(cur.fetchone()) + conn.close() + return obj diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..54bc807 --- /dev/null +++ b/app/security.py @@ -0,0 +1,25 @@ +import hashlib +import secrets + + +def hash_password(password: str, salt: str | None = None) -> tuple[str, str]: + """PBKDF2-SHA256 хэш пароля с солью.""" + if salt is None: + salt = secrets.token_hex(16) # новая соль при регистрации + + pwd_hash = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), 100_000 + ).hex() + + return pwd_hash, salt + + +def verify_password(password: str, salt: str, password_hash: str) -> bool: + """Сравнивает пароль с сохранённым хэшем.""" + new_hash, _ = hash_password(password, salt) + return secrets.compare_digest(new_hash, password_hash) # защита от timing-атак + + +def generate_token() -> str: + """Генерирует случайный токен сессии.""" + return secrets.token_hex(32)