Загрузить файлы в «app»

This commit is contained in:
2026-07-02 14:24:47 +00:00
parent 603977ff2f
commit 0593a483d8
4 changed files with 530 additions and 0 deletions

BIN
app/__init__.py Normal file

Binary file not shown.

82
app/database.py Normal file
View File

@@ -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()

423
app/main.py Normal file
View File

@@ -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

25
app/security.py Normal file
View File

@@ -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)