From b30ce9ce2aca26246c15ff79fe0dd398a5bde8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=9A=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D1=81?= Date: Sat, 21 Feb 2026 13:06:02 +0700 Subject: [PATCH] Static API Key for security --- main.py | 70 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/main.py b/main.py index 9d40871..ba27ffd 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,21 @@ import asyncio import logging -from contextlib import asynccontextmanager +import os from typing import Optional, List from datetime import datetime, timedelta +from contextlib import asynccontextmanager +from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, Depends, HTTPException, Security, APIRouter from fastapi.staticfiles import StaticFiles +from fastapi.security import APIKeyHeader +from starlette.status import HTTP_403_FORBIDDEN + from sqlalchemy import select +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + from app.core.discovery import DiscoveryService from app.core.state import state_manager from app.core.scheduler import start_scheduler, scheduler, execute_lamp_command @@ -16,8 +24,11 @@ from app.core.database import init_db, async_session from app.models.device import GroupModel, DeviceModel, GroupCreateSchema from app.models.schedule import ScheduleTask -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger + +load_dotenv() + +API_KEY = os.getenv("IGNIS_API_KEY") +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" @@ -28,6 +39,21 @@ discovery = DiscoveryService() wiz = WizDriver() +async def verify_token(header_value: str = Depends(api_key_header)): + # Если ключ не задан в .env, пускаем (для локальной разработки), + # либо строго требуем, если мы в продакшене + if not API_KEY: + return None + if header_value == API_KEY: + return header_value + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials" + ) + + +api_router = APIRouter(dependencies=[Depends(verify_token)]) + + @asynccontextmanager async def lifespan(app: FastAPI): """Жизненный цикл приложения.""" @@ -75,24 +101,24 @@ app = FastAPI(title="Ignis Core API", lifespan=lifespan) # --- Эндпоинты Устройств --- -@app.get("/devices") +@api_router.get("/devices") async def get_all_devices(): """Список всех обнаруженных ламп.""" return state_manager.devices -@app.get("/groups") +@api_router.get("/groups") async def get_groups(): """Список всех созданных люстр/групп.""" return state_manager.groups -@app.get("/scenes") +@api_router.get("/scenes") async def get_scenes(): return wiz.SCENES -@app.post("/groups") +@api_router.post("/groups") async def create_group(data: GroupCreateSchema): async with async_session() as session: existing = await session.get(GroupModel, data.id) @@ -109,7 +135,7 @@ async def create_group(data: GroupCreateSchema): return {"status": "created", "group": data.name} -@app.post("/control/device/{device_id}") +@api_router.post("/control/device/{device_id}") async def control_device( device_id: str, state: Optional[bool] = None, @@ -144,7 +170,7 @@ async def control_device( return {"device_id": device_id, "applied": params, "result": result} -@app.post("/control/group/{group_id}") +@api_router.post("/control/group/{group_id}") async def control_group( group_id: str, state: Optional[bool] = None, @@ -182,7 +208,7 @@ async def control_group( return {"status": "ok", "applied": params, "sent_to": ips} -@app.delete("/groups/{group_id}") +@api_router.delete("/groups/{group_id}") async def delete_group(group_id: str): async with async_session() as session: # Ищем в базе @@ -204,7 +230,7 @@ async def delete_group(group_id: str): return {"status": "deleted", "id": group_id} -@app.post("/discovery/rescan") +@api_router.post("/discovery/rescan") async def rescan_network(): logger.info("🔄 Ручной перезапуск сканирования сети...") @@ -215,7 +241,7 @@ async def rescan_network(): return {"status": "ok", "found": len(state_manager.devices)} -@app.post("/control/device/{device_id}/blink") +@api_router.post("/control/device/{device_id}/blink") async def blink_device(device_id: str): device = state_manager.devices.get(device_id) if not device: @@ -231,7 +257,7 @@ async def blink_device(device_id: str): return {"status": "blink_sent"} -@app.post("/schedules/once") +@api_router.post("/schedules/once") async def add_once_task(device_id: str, minutes: int, state: bool): device = state_manager.devices.get(device_id) if not device: @@ -250,7 +276,7 @@ async def add_once_task(device_id: str, minutes: int, state: bool): return {"status": "scheduled", "job_id": job.id, "run_at": run_time.isoformat()} -@app.post("/schedules/group/once") +@api_router.post("/schedules/group/once") async def add_group_once_task(group_id: str, minutes: int, state: bool): group = state_manager.groups.get(group_id) if not group: @@ -276,7 +302,7 @@ async def add_group_once_task(group_id: str, minutes: int, state: bool): } -@app.get("/schedules/active") +@api_router.get("/schedules/active") async def get_active_jobs(): jobs = [] for job in scheduler.get_jobs(): @@ -291,7 +317,7 @@ async def get_active_jobs(): return {"active_jobs": jobs} -@app.delete("/schedules/{job_id}") +@api_router.delete("/schedules/{job_id}") async def cancel_task(job_id: str): try: scheduler.remove_job(job_id) @@ -300,7 +326,7 @@ async def cancel_task(job_id: str): raise HTTPException(status_code=404, detail="Задача не найдена") -@app.post("/schedules/at") +@api_router.post("/schedules/at") async def add_task_at_time( target_id: str, run_at: datetime, @@ -344,7 +370,7 @@ async def add_task_at_time( return {"status": "scheduled", "jobs": job_ids, "run_at": run_at} -@app.post("/schedules/cron") +@api_router.post("/schedules/cron") async def add_cron_task( target_id: str, hour: str, @@ -384,9 +410,9 @@ async def add_cron_task( return {"status": "cron_scheduled", "jobs": job_ids} -@app.get("/schedules/tasks") +@api_router.get("/schedules/tasks") async def get_all_tasks(): - """Тот самый расширенный список задач для бота или фронта""" + """Список задач для бота или фронта""" jobs = [] for job in scheduler.get_jobs(): jobs.append( @@ -402,6 +428,8 @@ async def get_all_tasks(): return {"tasks": jobs} +app.include_router(api_router) + # --- МОНТИРОВАНИЕ СТАТИКИ (ДОЛЖНО БЫТЬ ПОСЛЕ ВСЕХ API МАРШРУТОВ) --- app.mount("/", StaticFiles(directory="static", html=True), name="static")