Мастерская сервисов
Находите и публикуйте сервисы для тренировок Attack-Defense.
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfd41de --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Local secrets and configuration +.env +.evn + +# Python virtual environments and cache +.venv/ +__pycache__/ +*.py[cod] + +# Editor and OS files +.vscode/ +.DS_Store +Thumbs.db diff --git a/database.py b/database.py new file mode 100644 index 0000000..7467e20 --- /dev/null +++ b/database.py @@ -0,0 +1,58 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv +from sqlalchemy import Boolean, ForeignKey, Integer, LargeBinary, String, Text, create_engine, text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +load_dotenv(Path(__file__).with_name(".evn"), override=True) +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError("DATABASE_URL is not configured") + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) + + +class Base(DeclarativeBase): + pass + + +class Service(Base): + __tablename__ = "services" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + description: Mapped[str] = mapped_column(Text, default="") + category: Mapped[str] = mapped_column(String(50)) + author: Mapped[str] = mapped_column(String(100), default="anonymous") + selected: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + archive_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + files: Mapped[list["ServiceFile"]] = relationship( + back_populates="service", cascade="all, delete-orphan" + ) + + +class ServiceFile(Base): + __tablename__ = "service_files" + + id: Mapped[int] = mapped_column(primary_key=True) + service_id: Mapped[int] = mapped_column(ForeignKey("services.id")) + path: Mapped[str] = mapped_column(String(500)) + content: Mapped[bytes] = mapped_column(LargeBinary) + service: Mapped[Service] = relationship(back_populates="files") + + +def init_db() -> None: + Base.metadata.create_all(engine) + # Short MVP migration: existing hackathon databases may lack this new field. + with engine.begin() as connection: + connection.execute( + text( + "ALTER TABLE services " + "ADD COLUMN IF NOT EXISTS selected BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) + connection.execute( + text("ALTER TABLE services ADD COLUMN IF NOT EXISTS archive_key VARCHAR(500)") + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..25103ac --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import FileResponse + +from database import init_db +from routers import router + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + yield + + +app = FastAPI(title="ADa Workshop", lifespan=lifespan) +app.include_router(router) + + +@app.get("/", response_class=FileResponse) +def workshop(): + return Path(__file__).with_name("workshop.html") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a8fb25 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +boto3==1.43.69 +fastapi==0.141.1 +psycopg[binary]==3.3.4 +python-dotenv==1.2.2 +python-multipart==0.0.32 +SQLAlchemy==2.0.52 +uvicorn==0.52.1 diff --git a/routers.py b/routers.py new file mode 100644 index 0000000..7824770 --- /dev/null +++ b/routers.py @@ -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 diff --git a/workshop.html b/workshop.html new file mode 100644 index 0000000..e5cbf04 --- /dev/null +++ b/workshop.html @@ -0,0 +1,266 @@ + + +
+ + +Находите и публикуйте сервисы для тренировок Attack-Defense.