Files
drhider/docs/MIGRATION-GUIDE.md

9.8 KiB
Raw Permalink Blame History

Инструкция: перенос UI и загрузки файлов с ВМ на Flask (Штурвал)

Дата: 2026-07-14 Основано на: drhider v0.0.26 — проверено в бою


Архитектура загрузки (drhider как образец)

Браузер (index.html)
    │  JS: uploadFiles() — цикл по файлам
    │  XHR POST /api/upload (FormData, по одному файлу)
    ▼
Flask (api_bp.py)
    │  upload(): сохраняет в сессию → {session_id}
    │  process_stream(): SSE — обработка с прогрессом
    ▼
Python (drhider/*.py)
    │  obfuscate_files() — основная логика
    ▼
Flask → ZIP + CSV → браузеру

Файлы, отвечающие за UI и загрузку

1. site/templates/index.html — весь фронтенд

Ключевые элементы:

<!-- Файловый инпут — множественный выбор -->
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.txt,.zip">

<!-- Таблица выбранных файлов -->
<table>
  <tbody id="fileList"></tbody>
</table>

<!-- Статус-бар -->
<div class="status" id="status"></div>

<!-- Кнопки скачивания (скрыты до готовности) -->
<div class="dl-btns" id="dlBtns">
  <button onclick="downloadZip()">📦 Скачать ZIP</button>
  <button onclick="downloadCsv()">📋 Скачать CSV</button>
</div>

Ключевые JS-функции:

Функция Что делает
resetAll() Сброс всего состояния (F5, между загрузками)
rr() Перерисовка таблицы файлов
uploadFiles() Главная — цикл загрузки + SSE-обработка
ss(idx, html) Обновление ячейки статуса в таблице
downloadZip() / downloadCsv() Скачивание результатов

Критичные исправления (v0.0.26 — v0.0.29):

// 1. Прогрев upstream при загрузке страницы — обязательно!
window.addEventListener('load', () => {
  sf = []; fi.value = ''; rr();
  fetch('/health').catch(() => {});  // ← обязательно! греет upstream
});

// 2. Abort предыдущих запросов перед новым
let activeES = null;   // активный EventSource
let activeXHR = null;  // активный XHR

// 3. Защита от F5 во время загрузки
window.addEventListener('beforeunload', () => resetAll());

// 4. Сохранение списка файлов до очистки
const files = sf.slice();  // копия перед resetAll()

// 5. Выбор файлов — ДОБАВЛЯЕТ в таблицу (не стирает, с дедупом)
fi.addEventListener('change', () => {
  const incoming = Array.from(fi.files);
  const seen = new Set(sf.map(f => f.name + '|' + f.size));
  for (const f of incoming) {
    if (!seen.has(f.name + '|' + f.size)) sf.push(f);
  }
  const d = new DataTransfer();
  sf.forEach(f => d.items.add(f));
  fi.files = d.files;
  rr();
});

// 6. Цикл загрузки (fetch, не XHR — XHR давал RST на первом запросе)
for (let i = 0; i < total; i++) {
  const fd = new FormData();
  fd.append('files', f, f.name);
  if (currentSid) fd.append('session', currentSid);
  const resp = await fetch('/api/upload?_=' + Date.now(), {  // ← fetch, не XHR
    method: 'POST', body: fd
  });
  const data = await resp.json();
  currentSid = data.session;
}

// 7. SSE-обработка
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', ...);  // файл начат
activeES.addEventListener('done', ...);   // файл готов
activeES.addEventListener('complete', ...); // всё готово

// 8. Скачивание ZIP/CSV — через fetch+Blob (не window.location!)
function downloadZip() {
  if (!currentSid) return;
  fetch('/api/download/' + currentSid)
    .then(r => {
      const disp = r.headers.get('Content-Disposition');
      const m = disp && disp.match(/filename="?(.+?)"?$/);
      return Promise.all([r.blob(), m ? m[1] : 'result.zip']);
    })
    .then(([blob, fname]) => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = fname;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(a.href);
    });
}

2. site/routes/api_bp.py — API эндпоинты

POST /api/upload              — загрузка одного файла → {session_id}
GET  /api/process_stream/<sid> — SSE: обработка с прогрессом
POST /api/process/<sid>       — обработка без SSE (legacy)
GET  /api/download/<sid>      — скачать ZIP
GET  /api/csv/<sid>           — скачать CSV отдельно

Критичные заголовки для SSE:

return Response(
    stream_with_context(generate()),
    content_type="text/event-stream",
    headers={
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no"   # ← без этого nginx буферизует SSE
    }
)

Критично: GeneratorExit в SSE-генераторе:

def generate():
    for idx, (fname, content) in enumerate(files):
        try:
            yield f"event: start\ndata: ...\n\n"
        except GeneratorExit:
            return  # клиент отключился — не обрабатываем дальше
        
        # ... обработка файла ...
        
        try:
            yield f"event: done\ndata: ...\n\n"
        except GeneratorExit:
            return

3. site/routes/health_bp.py — liveness probe

@health_bp.route("/health")
def health():
    return jsonify({"ok": True, "version": "0.0.1"})

Обязательно для Штурвала.

4. site/app.py — точка входа

Критичные настройки:

VERSION = "0.0.1"

def create_app():
    app = Flask(__name__)
    app.config["VERSION"] = VERSION
    app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024  # 200 MB

    from routes import register_routes
    register_routes(app)

    @app.after_request
    def no_cache(response):
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
        return response

    return app

app = create_app()

Пошаговая инструкция миграции

Шаг 1: Скопировать скелет UI

Скопировать site/templates/index.html из drhider как основу. Заменить:

  • Заголовок (<title>, .title, .card-header)
  • Текст описания
  • accept в <input type="file"> если другие форматы
  • Логику в uploadFiles() — вызов своего API вместо obfuscate_files

Шаг 2: Адаптировать API

Скопировать site/routes/api_bp.py, заменить:

  • Название blueprint'а
  • Логику в process_stream() — вызов своей функции вместо obfuscate_files()
  • Формат SSE-событий если нужен другой

Шаг 3: Health endpoint

Скопировать site/routes/health_bp.py как есть. Только версию поменять.

Шаг 4: app.py

Скопировать site/app.py, заменить:

  • VERSION
  • MAX_CONTENT_LENGTH если нужен другой лимит
  • Импорт своих blueprint'ов

Шаг 5: session.py

Скопировать site/session.py как есть. Это in-memory хранилище загруженных файлов и результатов.

Шаг 6: Интеграция бизнес-логики

Твоя функция обработки должна принимать тот же интерфейс что и obfuscate_files:

def твоя_функция(files, **kwargs):
    """
    Args:
        files: list of (filename: str, content: bytes, mimetype: str)
    Returns:
        zip_bytes: bytes  — ZIP-архив с результатами
        csv_str: str      — CSV с маппингом (или "")
    """

Чек-лист перед деплоем

  • /health возвращает {"ok": true}
  • MAX_CONTENT_LENGTH достаточен для файлов
  • no_cache after_request есть
  • X-Accel-Buffering: no на SSE-эндпоинте
  • GeneratorExit в каждом yield SSE-генератора
  • stream_with_context оборачивает генератор
  • fetch('/health') при загрузке страницы
  • beforeunloadresetAll()
  • activeXHR и activeES очищаются перед новым запуском
  • fetch вместо XHR (XHR на первом запросе даёт RST)
  • Выбор файлов — добавляет, не стирает (с дедупом)
  • Connection: closeНЕ ставить (Waitress/PEP 3333 запрещает hop-by-hop)

Ссылки на файлы-образцы

Что Где в drhider
HTML/JS/CSS фронтенд site/templates/index.html
API (upload + SSE + download) site/routes/api_bp.py
Health probe site/routes/health_bp.py
Точка входа Flask site/app.py
In-memory сессии site/session.py
Полная архитектура docs/ARCHITECTURE.md
Решённые проблемы кластера PROBLEM-AND-SOLUTION.md