Files
workshop/routers.py
2026-08-13 00:01:54 +03:00

205 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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