Add Workshop MVP

This commit is contained in:
for_rest
2026-08-13 00:01:54 +03:00
parent 71fbefad55
commit c089d5372a
6 changed files with 571 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -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

58
database.py Normal file
View File

@@ -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)")
)

23
main.py Normal file
View File

@@ -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")

7
requirements.txt Normal file
View File

@@ -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

204
routers.py Normal file
View 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

266
workshop.html Normal file
View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Врата ADA — Мастерская</title>
<style>
:root { color-scheme: dark; --bg:#0a0a0c; --card:#111216; --muted:#1d1f25; --border:#292c34; --text:#f4f4f5; --sub:#979da8; --green:#22c55e; --green-dark:#062d15; --red:#fb7185; font-family: Inter, Arial, sans-serif; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; color:var(--text); background:var(--bg); }
button, input, textarea, select { font:inherit; }
button { cursor:pointer; }
.sidebar { position:fixed; z-index:20; inset:0 auto 0 0; display:flex; flex-direction:column; width:256px; border-right:1px solid var(--border); background:var(--card); }
.brand { height:64px; display:flex; align-items:center; gap:11px; padding:0 20px; border-bottom:1px solid var(--border); font-size:18px; font-weight:700; color:var(--green); }
.brand-mark { display:grid; place-items:center; width:31px; height:31px; border-radius:8px; color:#021508; background:var(--green); }
.nav { flex:1; padding:18px 10px; overflow:auto; }
.nav-title { margin:0 10px 8px; color:#707784; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
.nav-item { display:flex; align-items:center; gap:12px; width:100%; margin:2px 0; padding:10px 12px; border:0; border-radius:8px; color:var(--sub); background:transparent; text-align:left; font-size:14px; font-weight:600; }
.nav-item:hover { color:var(--text); background:var(--muted); }
.nav-item.active { color:var(--green); background:rgba(34,197,94,.1); }
.nav-icon { width:20px; text-align:center; }
.nav-bottom { padding:12px 10px; border-top:1px solid var(--border); }
.user { display:flex; align-items:center; gap:10px; padding:9px 10px; color:var(--sub); font-size:13px; }
.avatar { display:grid; place-items:center; width:31px; height:31px; border-radius:50%; background:var(--green); color:#052b12; font-weight:800; }
.header { position:fixed; z-index:10; top:0; right:0; left:256px; height:64px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:0 28px; border-bottom:1px solid var(--border); background:rgba(17,18,22,.95); backdrop-filter:blur(8px); }
.search-wrap { position:relative; width:min(430px,100%); }
.search-wrap span { position:absolute; left:12px; top:10px; color:var(--sub); }
.search { width:100%; padding:10px 36px; border:1px solid var(--border); border-radius:8px; outline:0; color:var(--text); background:var(--bg); font-size:14px; }
.search:focus { border-color:rgba(34,197,94,.8); box-shadow:0 0 0 2px rgba(34,197,94,.12); }
.header-right { display:flex; align-items:center; gap:10px; color:var(--sub); font-size:13px; }
.main { min-height:100vh; margin-left:256px; padding:96px 28px 34px; }
.page { max-width:1180px; margin:auto; }
.page-top { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-bottom:26px; }
h1 { margin:0 0 7px; font-size:30px; letter-spacing:-.025em; }
.subtitle { margin:0; color:var(--sub); font-size:14px; }
.primary { display:inline-flex; align-items:center; gap:8px; padding:10px 14px; border:0; border-radius:8px; color:#04230e; background:var(--green); font-size:14px; font-weight:800; }
.primary:hover { background:#34d572; }
.layout { display:grid; grid-template-columns:240px minmax(0,1fr); gap:22px; }
.filters, .service, .upload { border:1px solid var(--border); border-radius:12px; background:var(--card); }
.filters { align-self:start; padding:18px; }
.filters h2, .upload h2 { margin:0 0 15px; font-size:15px; }
.filter-label { display:block; margin:18px 0 8px; color:var(--sub); font-size:12px; font-weight:700; text-transform:uppercase; }
.filter-label:first-of-type { margin-top:0; }
.category-list { display:flex; flex-wrap:wrap; gap:7px; }
.chip { padding:7px 9px; border:1px solid var(--border); border-radius:7px; color:var(--sub); background:transparent; font-size:12px; }
.chip:hover { color:var(--text); background:var(--muted); }
.chip.active { border-color:var(--green); color:var(--green); background:var(--green-dark); }
.selected { width:100%; margin-top:14px; padding:9px; border:1px solid var(--border); border-radius:8px; color:var(--sub); background:transparent; text-align:left; font-size:13px; }
.selected.active { border-color:var(--green); color:var(--green); background:var(--green-dark); }
.catalog-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:var(--sub); font-size:13px; }
.services { display:grid; gap:12px; }
.service { padding:19px; transition:border-color .15s, transform .15s; }
.service:hover { border-color:rgba(34,197,94,.55); transform:translateY(-1px); }
.service-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; }
.service h2 { margin:10px 0 7px; font-size:18px; }
.tag { display:inline-block; padding:4px 8px; border-radius:5px; color:#86efac; background:rgba(34,197,94,.12); font-size:12px; font-weight:700; }
.service p { margin:0; color:var(--sub); font-size:14px; line-height:1.5; }
.service-foot { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:17px; padding-top:14px; border-top:1px solid var(--border); }
.author { color:var(--sub); font-size:12px; }
.choose { padding:8px 10px; border:1px solid var(--border); border-radius:7px; color:var(--text); background:var(--muted); font-size:13px; font-weight:700; }
.choose:hover { border-color:var(--green); color:var(--green); }
.choose.selected-service { border-color:var(--green); color:#052b12; background:var(--green); }
.empty { display:grid; min-height:300px; place-items:center; padding:25px; border:1px dashed var(--border); border-radius:12px; color:var(--sub); text-align:center; }
.message { min-height:23px; margin:0 0 10px; color:#86efac; font-size:13px; }
.message.error { color:var(--red); }
.modal { position:fixed; z-index:30; inset:0; display:grid; place-items:center; padding:18px; background:rgba(0,0,0,.64); }
.modal[hidden] { display:none; }
.upload { width:min(520px,100%); padding:22px; box-shadow:0 24px 80px rgba(0,0,0,.5); }
.modal-head { display:flex; align-items:center; justify-content:space-between; gap:15px; }
.close { width:31px; height:31px; border:0; border-radius:7px; color:var(--sub); background:transparent; font-size:21px; }
.close:hover { color:var(--text); background:var(--muted); }
form { display:grid; gap:11px; }
.field-label { color:var(--sub); font-size:12px; font-weight:700; }
input, textarea, select { width:100%; padding:10px; border:1px solid var(--border); border-radius:8px; outline:0; color:var(--text); background:var(--bg); }
input:focus, textarea:focus, select:focus { border-color:var(--green); }
textarea { min-height:90px; resize:vertical; }
.hint { color:var(--sub); font-size:12px; line-height:1.45; }
.form-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:5px; }
.secondary { padding:10px 14px; border:1px solid var(--border); border-radius:8px; color:var(--text); background:transparent; }
@media (max-width:800px) { .sidebar { position:static; width:auto; height:auto; } .nav, .nav-bottom { display:none; } .brand { height:56px; } .header { position:static; left:auto; height:auto; padding:12px 18px; } .header-right { display:none; } .main { margin-left:0; padding:30px 18px; } .layout { grid-template-columns:1fr; } .filters { display:block; } .page-top { align-items:stretch; flex-direction:column; } .primary { justify-content:center; } }
</style>
</head>
<body>
<aside class="sidebar">
<div class="brand"><span class="brand-mark"></span>Врата ADA</div>
<nav class="nav">
<p class="nav-title">Навигация</p>
<button class="nav-item" type="button"><span class="nav-icon"></span>Обзор</button>
<button class="nav-item" type="button"><span class="nav-icon"></span>Матчи</button>
<button class="nav-item" type="button"><span class="nav-icon"></span>Рейтинг</button>
<button class="nav-item active" type="button"><span class="nav-icon"></span>Мастерская</button>
<button class="nav-item" type="button"><span class="nav-icon"></span>Команды</button>
<button class="nav-item" type="button"><span class="nav-icon"></span>Обучение</button>
<p class="nav-title" style="margin-top:26px">Система</p>
<button class="nav-item" type="button"><span class="nav-icon"></span>Аналитика</button>
<button class="nav-item" type="button"><span class="nav-icon"></span>Логи</button>
</nav>
<div class="nav-bottom"><div class="user"><span class="avatar">A</span><span>ADa Player</span></div></div>
</aside>
<header class="header">
<div class="search-wrap"><span></span><input id="search" class="search" type="search" placeholder="Поиск сервисов..."></div>
<div class="header-right"><span></span><span>Workshop</span></div>
</header>
<main class="main">
<div class="page">
<div class="page-top">
<div><h1>Мастерская сервисов</h1><p class="subtitle">Находите и публикуйте сервисы для тренировок Attack-Defense.</p></div>
<button id="open-upload" class="primary" type="button"> Загрузить сервис</button>
</div>
<div class="layout">
<aside class="filters">
<h2>Фильтры</h2>
<span class="filter-label">Категория</span>
<div id="categories" class="category-list"></div>
<button id="selected-filter" class="selected" type="button">◉ Выбранные для игры</button>
</aside>
<section>
<div id="message" class="message" aria-live="polite"></div>
<div class="catalog-head"><span id="counter">0 сервисов</span><span>Сначала новые</span></div>
<div id="services" class="services"></div>
</section>
</div>
</div>
</main>
<div id="upload-modal" class="modal" hidden>
<section class="upload" role="dialog" aria-modal="true" aria-labelledby="upload-title">
<div class="modal-head"><h2 id="upload-title">Опубликовать сервис</h2><button id="close-upload" class="close" type="button" aria-label="Закрыть">×</button></div>
<form id="upload-form">
<label class="field-label">Название<input name="name" maxlength="100" required placeholder="Например, Secure Notes"></label>
<label class="field-label">Автор<input name="author" maxlength="100" placeholder="Ваш ник"></label>
<label class="field-label">Категория<select name="category" id="upload-category"></select></label>
<label class="field-label">Описание<textarea name="description" maxlength="2000" placeholder="Что нужно знать о сервисе?"></textarea></label>
<label class="field-label">Архив сервиса<input name="file" type="file" accept=".zip,application/zip" required></label>
<span class="hint">ZIP до 5 МБ. Обязательны: checker/checker.py, service/docker-compose.yml и service/Dockerfile.api.</span>
<div class="form-actions"><button id="cancel-upload" class="secondary" type="button">Отмена</button><button class="primary" type="submit">Опубликовать</button></div>
</form>
</section>
</div>
<script>
var state = { category: "", selected: false };
var categoriesNode = document.getElementById("categories");
var servicesNode = document.getElementById("services");
var messageNode = document.getElementById("message");
var searchNode = document.getElementById("search");
var modal = document.getElementById("upload-modal");
var searchTimer;
function showMessage(text, error) {
messageNode.textContent = text || "";
messageNode.className = error ? "message error" : "message";
}
function setModal(open) { modal.hidden = !open; }
async function api(response) {
var data = await response.json().catch(function () { return {}; });
if (!response.ok) throw new Error(data.detail || "Не удалось выполнить запрос");
return data;
}
function makeChip(label, value) {
var button = document.createElement("button");
button.type = "button";
button.className = "chip" + (state.category === value ? " active" : "");
button.textContent = label;
button.onclick = function () { state.category = value; renderCategories(window.workshopCategories); loadServices(); };
return button;
}
function renderCategories(categories) {
window.workshopCategories = categories;
categoriesNode.replaceChildren(makeChip("Все", ""));
categories.forEach(function (category) { categoriesNode.append(makeChip(category, category)); });
var select = document.getElementById("upload-category");
select.replaceChildren();
categories.forEach(function (category) {
var option = document.createElement("option");
option.value = option.textContent = category;
select.append(option);
});
}
function renderServices(services) {
servicesNode.replaceChildren();
document.getElementById("counter").textContent = services.length + " сервисов";
if (!services.length) {
var empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "Сервисов по этому фильтру пока нет.";
servicesNode.append(empty);
return;
}
services.forEach(function (service) {
var card = document.createElement("article");
card.className = "service";
var head = document.createElement("div");
head.className = "service-head";
var titleBlock = document.createElement("div");
var tag = document.createElement("span");
tag.className = "tag";
tag.textContent = service.category;
var title = document.createElement("h2");
title.textContent = service.name;
titleBlock.append(tag, title);
var description = document.createElement("p");
description.textContent = service.description || "Описание не добавлено.";
head.append(titleBlock);
var foot = document.createElement("div");
foot.className = "service-foot";
var author = document.createElement("span");
author.className = "author";
author.textContent = "Автор: " + service.author;
var choose = document.createElement("button");
choose.type = "button";
choose.className = "choose" + (service.selected ? " selected-service" : "");
choose.textContent = service.selected ? "✓ Выбран для игры" : "Выбрать для игры";
choose.onclick = function () { setSelection(service.id, !service.selected); };
foot.append(author, choose);
card.append(head, description, foot);
servicesNode.append(card);
});
}
async function loadServices() {
var params = new URLSearchParams();
if (state.category) params.set("category", state.category);
if (state.selected) params.set("selected", "true");
if (searchNode.value.trim()) params.set("q", searchNode.value.trim());
try { renderServices(await api(await fetch("/api/services?" + params))); }
catch (error) { showMessage(error.message, true); }
}
async function setSelection(id, selected) {
try {
await api(await fetch("/api/services/" + id + "/selection", {
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ selected: selected })
}));
showMessage(selected ? "Сервис выбран для игры." : "Сервис убран из выбора.");
loadServices();
} catch (error) { showMessage(error.message, true); }
}
document.getElementById("selected-filter").onclick = function () {
state.selected = !state.selected;
this.classList.toggle("active", state.selected);
this.textContent = state.selected ? "✓ Показаны выбранные" : "◉ Выбранные для игры";
loadServices();
};
document.getElementById("open-upload").onclick = function () { setModal(true); };
document.getElementById("close-upload").onclick = function () { setModal(false); };
document.getElementById("cancel-upload").onclick = function () { setModal(false); };
modal.onclick = function (event) { if (event.target === modal) setModal(false); };
searchNode.oninput = function () { clearTimeout(searchTimer); searchTimer = setTimeout(loadServices, 250); };
document.getElementById("upload-form").onsubmit = async function (event) {
event.preventDefault();
var form = event.currentTarget;
try {
var service = await api(await fetch("/api/services", { method: "POST", body: new FormData(form) }));
form.reset();
setModal(false);
showMessage("Опубликован сервис: " + service.name);
loadServices();
} catch (error) { showMessage(error.message, true); }
};
async function start() {
try {
renderCategories(await api(await fetch("/api/categories")));
loadServices();
} catch (error) { showMessage(error.message, true); }
}
start();
</script>
</body>
</html>