Загрузить файлы в «app»
This commit is contained in:
BIN
app/__init__.py
Normal file
BIN
app/__init__.py
Normal file
Binary file not shown.
82
app/database.py
Normal file
82
app/database.py
Normal 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
423
app/main.py
Normal 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
25
app/security.py
Normal 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)
|
||||
Reference in New Issue
Block a user