import json import sqlite3 import logging import urllib.request import os from datetime import datetime, timedelta from typing import List, Dict, Any, Optional import jwt from passlib.context import CryptContext from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from pydantic import BaseModel from llm.agent import process_chat_message from llm.db_tools import db_get_tasks, DB_PATH from llm.file_parser import extract_text_from_file logging.basicConfig( level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" ALGORITHM = "HS256" OLLAMA_URL = "http://192.168.11.3:11434/api/chat" MODEL_NAME = "qwen2.5:14b" pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") security = HTTPBearer() def get_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def create_access_token(user_id: int, username: str, is_admin: bool) -> str: payload = { "sub": str(user_id), "username": username, "is_admin": is_admin, "exp": datetime.utcnow() + timedelta(days=30) } return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: try: token = credentials.credentials payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) user_id = int(payload.get("sub")) username = payload.get("username") is_admin = bool(payload.get("is_admin", False)) return {"id": user_id, "username": username, "is_admin": is_admin} except Exception as e: logging.warning(f"Auth error: {e}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный или просроченный токен авторизации", headers={"WWW-Authenticate": "Bearer"}, ) app = FastAPI(title="SCUD Orion AI Context API") if os.path.exists("static"): app.mount("/static", StaticFiles(directory="static"), name="static") from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") return JSONResponse( status_code=422, content={"detail": exc.errors(), "body": str(exc)} ) class AuthRequest(BaseModel): username: str password: str class CreateUserRequest(BaseModel): username: str password: str full_name: Optional[str] = None is_admin: Optional[bool] = False class ChangePasswordRequest(BaseModel): old_password: str new_password: str # === API МАРШРУТЫ === @app.get("/") def read_root(): return FileResponse("static/index.html") @app.get("/favicon.ico") async def favicon(): file_path = os.path.join("static", "favicon.ico") if os.path.exists(file_path): return FileResponse(file_path) raise HTTPException(status_code=404) @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") # Игнорируем сканеры WordPress / PHP if any(clean_path.startswith(prefix) for prefix in ["wp-", "wordpress", "php", "cms", "shop"]): raise HTTPException(status_code=404, detail="Not Found") target = os.path.join("static", clean_path) if os.path.isfile(target): return FileResponse(target) filename = os.path.basename(clean_path) target_js = os.path.join("static/js", filename) if filename.endswith(".js") and os.path.isfile(target_js): return FileResponse(target_js, media_type="application/javascript") target_css = os.path.join("static/css", filename) if filename.endswith(".css") and os.path.isfile(target_css): return FileResponse(target_css, media_type="text/css") raise HTTPException(status_code=404, detail="File not found") @app.post("/api/v1/auth/login") def login(req: AuthRequest): username = req.username.strip().lower() logging.info(f"===> Попытка входа для пользователя: {username}") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) user = cursor.fetchone() conn.close() if not user: logging.warning(f"===> Ошибка: Пользователь {username} не найден") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") if not pwd_context.verify(req.password, user["password_hash"]): logging.warning(f"===> Ошибка: Неверный пароль для {username}") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") is_admin = bool(user["is_admin"]) or (user["username"] == "puh") token = create_access_token(user["id"], user["username"], is_admin) logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} @app.post("/api/v1/auth/change-password") def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not req.new_password or len(req.new_password) < 4: raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) user = cursor.fetchone() if not user or not pwd_context.verify(req.old_password, user["password_hash"]): conn.close() raise HTTPException(status_code=400, detail="Неверный старый пароль") new_hash = pwd_context.hash(req.new_password) cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) conn.commit() conn.close() logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}") return {"status": "success", "message": "Пароль успешно изменен"} @app.get("/api/v1/admin/users") def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") users = [dict(r) for r in cursor.fetchall()] conn.close() return users @app.post("/api/v1/admin/users") def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") username = req.username.strip().lower() if not username or not req.password: raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) if cursor.fetchone(): conn.close() raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") pwd_hash = pwd_context.hash(req.password) full_name = req.full_name.strip() if req.full_name else None is_admin = 1 if req.is_admin else 0 cursor.execute( "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", (username, pwd_hash, full_name, is_admin) ) conn.commit() conn.close() logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") return {"status": "success", "message": f"Пользователь {username} создан"} @app.delete("/api/v1/admin/users/{user_id}") def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") if user_id == current_user["id"]: raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") conn = get_db() cursor = conn.cursor() cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.commit() conn.close() logging.info(f"Удален пользователь ID: {user_id}") return {"status": "success", "message": "Пользователь удален"} @app.get("/api/v1/tasks") def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): return db_get_tasks(user_id=user["id"]) # ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ @app.post("/api/v1/chat") async def chat_endpoint( session_id: str = Form("web_session_main"), message: str = Form(""), file: Optional[UploadFile] = File(default=None), current_user: dict = Depends(get_current_user) ): logging.info(f"=== [CHAT API] Входящий запрос от user_id={current_user['id']}, file={file.filename if file else 'None'} ===") file_content_text = "" if file and file.filename: file_bytes = await file.read() file_content_text = extract_text_from_file(file_bytes, file.filename) reply, history = process_chat_message( user_id=current_user["id"], user_message=message, file_context=file_content_text, session_id=session_id ) return {"reply": reply, "history": history} # ЕДИНЫЙ ГОСТЕВОЙ ЧАТ (FormData + Файлы) @app.post("/api/v1/chat/guest") async def guest_chat_endpoint( session_id: str = Form("web_session_main"), message: str = Form(""), file: Optional[UploadFile] = File(default=None) ): logging.info(f"=== [GUEST CHAT API] Входящий запрос, file={file.filename if file else 'None'} ===") file_content_text = "" if file and file.filename: file_bytes = await file.read() file_content_text = extract_text_from_file(file_bytes, file.filename) reply, history = process_chat_message( user_id=0, user_message=message, file_context=file_content_text, session_id=session_id ) return {"reply": reply, "history": history} # === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") target = os.path.join("static", clean_path) if os.path.isfile(target): return FileResponse(target) filename = os.path.basename(clean_path) target_js = os.path.join("static/js", filename) if filename.endswith(".js") and os.path.isfile(target_js): return FileResponse(target_js, media_type="application/javascript") target_css = os.path.join("static/css", filename) if filename.endswith(".css") and os.path.isfile(target_css): return FileResponse(target_css, media_type="text/css") raise HTTPException(status_code=404, detail="File not found")