424 lines
14 KiB
Python
424 lines
14 KiB
Python
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
|