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

This commit is contained in:
2026-07-02 20:28:02 +00:00
parent a2e3e7a78d
commit 33607bae6e
5 changed files with 672 additions and 0 deletions

10
config.py Normal file
View File

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

312
database.py Normal file
View File

@@ -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": "", "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]

280
main.py Normal file
View File

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

50
schemas.py Normal file
View File

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

20
security.py Normal file
View File

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