Add Workshop MVP
This commit is contained in:
204
routers.py
Normal file
204
routers.py
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
Reference in New Issue
Block a user