399 lines
9.3 KiB
Python
399 lines
9.3 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Security, status
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.security import APIKeyHeader
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
from scripts.common import DB_FILE, ZPWIKI_ROOT
|
|
from scripts.rebuild_index import ReindexInProgressError, rebuild_index
|
|
from scripts.search_utils import search_database
|
|
|
|
|
|
MIN_SECRET_LENGTH = 32
|
|
SYNC_API_KEY_HEADER = "X-API-Key"
|
|
|
|
sync_api_key_scheme = APIKeyHeader(
|
|
name=SYNC_API_KEY_HEADER,
|
|
auto_error=False,
|
|
description="API kľúč pre manuálne spustenie reindexovania.",
|
|
)
|
|
|
|
|
|
class SearchRequest(BaseModel):
|
|
query: str = Field(..., min_length=1, max_length=500)
|
|
limit: int = Field(default=10, ge=1, le=50)
|
|
published_only: bool = False
|
|
max_per_document: int = Field(default=3, ge=0, le=10)
|
|
|
|
|
|
class SyncRequest(BaseModel):
|
|
pull_git: bool = Field(
|
|
default=False,
|
|
description="Pred reindexovaním vykoná git pull --ff-only.",
|
|
)
|
|
|
|
|
|
def required_environment_value(name: str) -> str:
|
|
value = os.getenv(name, "").strip()
|
|
|
|
if not value:
|
|
raise RuntimeError(f"Chýba povinná environment premenná {name}")
|
|
|
|
return value
|
|
|
|
|
|
def validate_secret(name: str) -> str:
|
|
value = required_environment_value(name)
|
|
|
|
if len(value) < MIN_SECRET_LENGTH:
|
|
raise RuntimeError(
|
|
f"{name} musí mať aspoň {MIN_SECRET_LENGTH} znakov"
|
|
)
|
|
|
|
return value
|
|
|
|
|
|
def expected_gitea_repository() -> str:
|
|
value = required_environment_value("EXPECTED_GITEA_REPOSITORY")
|
|
|
|
if "/" not in value:
|
|
raise RuntimeError(
|
|
"EXPECTED_GITEA_REPOSITORY musí mať tvar vlastník/repozitár"
|
|
)
|
|
|
|
return value
|
|
|
|
|
|
def webhook_should_pull_git() -> bool:
|
|
value = os.getenv("WEBHOOK_PULL_GIT", "false").strip().casefold()
|
|
|
|
return value in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def validate_security_configuration() -> None:
|
|
validate_secret("WEBHOOK_SECRET")
|
|
validate_secret("SYNC_API_KEY")
|
|
expected_gitea_repository()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
# Aplikácia sa nespustí s chýbajúcim alebo slabým tajomstvom.
|
|
validate_security_configuration()
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="ZP Agent API",
|
|
description="API pre vyhľadávanie v repozitári záverečných prác zpwiki.",
|
|
version="0.6.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
def require_sync_api_key(
|
|
api_key: str | None = Security(sync_api_key_scheme),
|
|
) -> None:
|
|
expected = validate_secret("SYNC_API_KEY")
|
|
|
|
if not api_key or not hmac.compare_digest(api_key, expected):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Neplatný alebo chýbajúci API kľúč",
|
|
headers={"WWW-Authenticate": "ApiKey"},
|
|
)
|
|
|
|
|
|
def verify_gitea_signature(
|
|
raw_body: bytes,
|
|
signature: str | None,
|
|
secret: str,
|
|
) -> bool:
|
|
if not signature:
|
|
return False
|
|
|
|
supplied = signature.strip().casefold()
|
|
|
|
# X-Gitea-Signature je čistý hex digest. Prefix prijímame iba
|
|
# kvôli kompatibilite s X-Hub-Signature-256.
|
|
if supplied.startswith("sha256="):
|
|
supplied = supplied.removeprefix("sha256=")
|
|
|
|
if len(supplied) != 64:
|
|
return False
|
|
|
|
try:
|
|
int(supplied, 16)
|
|
except ValueError:
|
|
return False
|
|
|
|
expected = hmac.new(
|
|
secret.encode("utf-8"),
|
|
raw_body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
return hmac.compare_digest(expected, supplied)
|
|
|
|
|
|
def repository_name_from_payload(
|
|
payload: dict[str, Any],
|
|
) -> str | None:
|
|
repository = payload.get("repository")
|
|
|
|
if not isinstance(repository, dict):
|
|
return None
|
|
|
|
value = (
|
|
repository.get("full_name")
|
|
or repository.get("name")
|
|
)
|
|
|
|
if not isinstance(value, str):
|
|
return None
|
|
|
|
value = value.strip()
|
|
|
|
return value or None
|
|
|
|
|
|
def same_repository(
|
|
actual: str,
|
|
expected: str,
|
|
) -> bool:
|
|
return hmac.compare_digest(
|
|
actual.casefold(),
|
|
expected.casefold(),
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, Any]:
|
|
return {
|
|
"status": "ok",
|
|
"database_exists": DB_FILE.exists(),
|
|
"database_path": str(DB_FILE),
|
|
"search_engine": "sqlite_fts5",
|
|
"zpwiki_root": str(ZPWIKI_ROOT),
|
|
"zpwiki_exists": ZPWIKI_ROOT.exists(),
|
|
"security_configured": all(
|
|
bool(os.getenv(name, "").strip())
|
|
for name in (
|
|
"WEBHOOK_SECRET",
|
|
"SYNC_API_KEY",
|
|
"EXPECTED_GITEA_REPOSITORY",
|
|
)
|
|
),
|
|
}
|
|
|
|
|
|
@app.post("/search")
|
|
def search(
|
|
request: SearchRequest,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
response = search_database(
|
|
DB_FILE,
|
|
request.query,
|
|
request.limit,
|
|
published_only=request.published_only,
|
|
max_per_document=request.max_per_document,
|
|
)
|
|
|
|
except FileNotFoundError as error:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
except ValueError as error:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
except RuntimeError as error:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
results = response["results"]
|
|
|
|
return {
|
|
"query": request.query,
|
|
"engine": response["engine"],
|
|
"strategies": response["strategies"],
|
|
"count": len(results),
|
|
"results": results,
|
|
}
|
|
|
|
|
|
@app.post(
|
|
"/sync",
|
|
dependencies=[Depends(require_sync_api_key)],
|
|
)
|
|
def sync(
|
|
request: SyncRequest,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
result = rebuild_index(
|
|
pull_git=request.pull_git
|
|
)
|
|
|
|
except ReindexInProgressError as error:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
except RuntimeError as error:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
return {
|
|
"status": "ok",
|
|
"pull_git": request.pull_git,
|
|
"duration_seconds": result["duration_seconds"],
|
|
"counts": result["counts"],
|
|
}
|
|
|
|
|
|
@app.post(
|
|
"/webhook/gitea",
|
|
response_model = None,
|
|
)
|
|
async def gitea_webhook(
|
|
request: Request,
|
|
x_gitea_event: str | None = Header(
|
|
default=None,
|
|
alias="X-Gitea-Event",
|
|
),
|
|
x_gitea_signature: str | None = Header(
|
|
default=None,
|
|
alias="X-Gitea-Signature",
|
|
),
|
|
) -> dict[str, Any] | JSONResponse:
|
|
raw_body = await request.body()
|
|
secret = validate_secret("WEBHOOK_SECRET")
|
|
|
|
if not verify_gitea_signature(
|
|
raw_body,
|
|
x_gitea_signature,
|
|
secret,
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Neplatný webhook podpis",
|
|
)
|
|
|
|
try:
|
|
payload = json.loads(
|
|
raw_body.decode("utf-8")
|
|
)
|
|
|
|
except (
|
|
UnicodeDecodeError,
|
|
json.JSONDecodeError,
|
|
) as error:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Webhook payload nie je platný JSON",
|
|
) from error
|
|
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Webhook payload musí byť JSON objekt",
|
|
)
|
|
|
|
if not x_gitea_event:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Chýba hlavička X-Gitea-Event",
|
|
)
|
|
|
|
if x_gitea_event.casefold() != "push":
|
|
return JSONResponse(
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
content={
|
|
"status": "ignored",
|
|
"reason": "unsupported_event",
|
|
"event": x_gitea_event,
|
|
},
|
|
)
|
|
|
|
repository_name = repository_name_from_payload(
|
|
payload
|
|
)
|
|
|
|
if repository_name is None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"Webhook payload neobsahuje "
|
|
"repository.full_name"
|
|
),
|
|
)
|
|
|
|
expected_repository = expected_gitea_repository()
|
|
|
|
if not same_repository(
|
|
repository_name,
|
|
expected_repository,
|
|
):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=(
|
|
"Webhook patrí neočakávanému "
|
|
"repozitáru"
|
|
),
|
|
)
|
|
|
|
try:
|
|
result = await asyncio.to_thread(
|
|
rebuild_index,
|
|
pull_git=webhook_should_pull_git(),
|
|
)
|
|
|
|
except ReindexInProgressError as error:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
except RuntimeError as error:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=str(error),
|
|
) from error
|
|
|
|
return {
|
|
"status": "ok",
|
|
"event": x_gitea_event,
|
|
"repository": repository_name,
|
|
"verified_by": "hmac_sha256",
|
|
"duration_seconds": result["duration_seconds"],
|
|
"counts": result["counts"],
|
|
}
|