205 lines
6.9 KiB
Python
205 lines
6.9 KiB
Python
import io
|
||
import os
|
||
from pathlib import PurePosixPath
|
||
from uuid import uuid4
|
||
from zipfile import BadZipFile, ZipFile
|
||
|
||
import boto3
|
||
from botocore.config import Config
|
||
from botocore.exceptions import BotoCoreError, ClientError
|
||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
|
||
from fastapi.concurrency import run_in_threadpool
|
||
from pydantic import BaseModel, ConfigDict
|
||
from sqlalchemy import or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from database import Service, engine
|
||
|
||
|
||
CATEGORIES = (
|
||
"Web",
|
||
"Binary",
|
||
"Network",
|
||
"Database",
|
||
"Authentication",
|
||
"Storage",
|
||
"Crypto",
|
||
"Other",
|
||
)
|
||
MAX_ARCHIVE_BYTES = 5_000_000
|
||
MAX_FILES = 512
|
||
MAX_UNPACKED_BYTES = 20_000_000
|
||
REQUIRED_ARCHIVE_FILES = {
|
||
"checker/checker.py",
|
||
"service/docker-compose.yml",
|
||
"service/Dockerfile.api",
|
||
}
|
||
|
||
router = APIRouter(prefix="/api", tags=["workshop"])
|
||
s3 = boto3.client(
|
||
"s3",
|
||
endpoint_url=os.getenv("S3_ENDPOINT_URL"),
|
||
aws_access_key_id=os.getenv("S3_ACCESS_KEY"),
|
||
aws_secret_access_key=os.getenv("S3_SECRET_KEY"),
|
||
region_name=os.getenv("S3_REGION", "us-east-1"),
|
||
config=Config(
|
||
proxies={},
|
||
signature_version="s3v4",
|
||
s3={"addressing_style": "path"},
|
||
),
|
||
)
|
||
|
||
|
||
def remove_expect_header(request, **_):
|
||
request.headers.pop("Expect", None)
|
||
|
||
|
||
s3.meta.events.register("before-send.s3.PutObject", remove_expect_header)
|
||
S3_BUCKET = os.getenv("S3_BUCKET", "workshop")
|
||
|
||
|
||
class ServiceOut(BaseModel):
|
||
id: int
|
||
name: str
|
||
description: str
|
||
category: str
|
||
author: str
|
||
selected: bool
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
|
||
class SelectionIn(BaseModel):
|
||
selected: bool
|
||
|
||
|
||
def get_service(db: Session, service_id: int) -> Service:
|
||
service = db.get(Service, service_id)
|
||
if not service:
|
||
raise HTTPException(status_code=404, detail="Сервис не найден")
|
||
return service
|
||
|
||
|
||
def safe_archive_path(path: str) -> bool:
|
||
normalized = path.replace("\\", "/")
|
||
return bool(path) and not normalized.startswith("/") and ".." not in PurePosixPath(normalized).parts
|
||
|
||
|
||
@router.get("/categories", response_model=list[str])
|
||
def list_categories():
|
||
return CATEGORIES
|
||
|
||
|
||
@router.get("/services", response_model=list[ServiceOut])
|
||
def list_services(
|
||
category: str | None = None,
|
||
q: str | None = None,
|
||
selected: bool | None = None,
|
||
):
|
||
with Session(engine) as db:
|
||
query = db.query(Service)
|
||
if category:
|
||
query = query.filter(Service.category == category)
|
||
if q and (query_text := q.strip()):
|
||
pattern = "%" + query_text + "%"
|
||
query = query.filter(
|
||
or_(
|
||
Service.name.ilike(pattern),
|
||
Service.description.ilike(pattern),
|
||
Service.author.ilike(pattern),
|
||
)
|
||
)
|
||
if selected is not None:
|
||
query = query.filter(Service.selected == selected)
|
||
return query.order_by(Service.id.desc()).all()
|
||
|
||
|
||
@router.post("/services", response_model=ServiceOut, status_code=status.HTTP_201_CREATED)
|
||
async def create_service(
|
||
file: UploadFile = File(...),
|
||
name: str = Form(..., max_length=100),
|
||
description: str = Form("", max_length=2000),
|
||
category: str = Form("Other", max_length=50),
|
||
author: str = Form("anonymous", max_length=100),
|
||
):
|
||
name, description, category, author = (
|
||
name.strip(),
|
||
description.strip(),
|
||
category.strip(),
|
||
author.strip() or "anonymous",
|
||
)
|
||
if not name:
|
||
raise HTTPException(status_code=422, detail="Укажите название сервиса")
|
||
if category not in CATEGORIES:
|
||
raise HTTPException(status_code=422, detail="Выберите категорию из списка")
|
||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||
raise HTTPException(status_code=415, detail="Нужен ZIP-архив сервиса")
|
||
|
||
archive_data = await file.read(MAX_ARCHIVE_BYTES + 1)
|
||
await file.close()
|
||
if len(archive_data) > MAX_ARCHIVE_BYTES:
|
||
raise HTTPException(status_code=413, detail="ZIP больше 5 МБ")
|
||
|
||
try:
|
||
with ZipFile(io.BytesIO(archive_data)) as archive:
|
||
entries = [entry for entry in archive.infolist() if not entry.is_dir()]
|
||
if not entries:
|
||
raise HTTPException(status_code=422, detail="В ZIP нет файлов")
|
||
if len(entries) > MAX_FILES:
|
||
raise HTTPException(status_code=413, detail="В ZIP больше 512 файлов")
|
||
if sum(entry.file_size for entry in entries) > MAX_UNPACKED_BYTES:
|
||
raise HTTPException(status_code=413, detail="Распакованный сервис больше 20 МБ")
|
||
if any(not safe_archive_path(entry.filename) for entry in entries):
|
||
raise HTTPException(status_code=422, detail="ZIP содержит небезопасный путь")
|
||
|
||
paths = {entry.filename.replace("\\", "/") for entry in entries}
|
||
missing = REQUIRED_ARCHIVE_FILES - paths
|
||
if missing:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="В ZIP нет обязательных файлов: " + ", ".join(sorted(missing)),
|
||
)
|
||
if any(not path.startswith(("checker/", "service/")) for path in paths):
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Файлы ZIP должны лежать только в checker/ или service/",
|
||
)
|
||
|
||
archive_key = f"services/{uuid4()}/service.zip"
|
||
try:
|
||
await run_in_threadpool(
|
||
s3.put_object,
|
||
Bucket=S3_BUCKET,
|
||
Key=archive_key,
|
||
Body=archive_data,
|
||
ContentType="application/zip",
|
||
)
|
||
except (BotoCoreError, ClientError) as error:
|
||
print(f"S3 upload error: {error}")
|
||
raise HTTPException(status_code=503, detail="S3-хранилище недоступно")
|
||
|
||
with Session(engine) as db:
|
||
service = Service(
|
||
name=name,
|
||
description=description,
|
||
category=category,
|
||
author=author,
|
||
archive_key=archive_key,
|
||
)
|
||
db.add(service)
|
||
db.commit()
|
||
db.refresh(service)
|
||
return service
|
||
except BadZipFile:
|
||
raise HTTPException(status_code=422, detail="Файл не является корректным ZIP-архивом")
|
||
|
||
|
||
@router.patch("/services/{service_id}/selection", response_model=ServiceOut)
|
||
def update_selection(service_id: int, payload: SelectionIn):
|
||
with Session(engine) as db:
|
||
service = get_service(db, service_id)
|
||
service.selected = payload.selected
|
||
db.commit()
|
||
db.refresh(service)
|
||
return service
|