302 lines
12 KiB
Python
302 lines
12 KiB
Python
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") |