v7.0.0: API upload/process/download, session.py, фронт без обработки — всё на бэкенде
Deploy drhider / validate (push) Waiting to run

This commit is contained in:
2026-07-13 08:12:20 +04:00
parent e590d8ab9a
commit 18922b486b
6 changed files with 299 additions and 84 deletions
@@ -0,0 +1,39 @@
# Запрос к Соннету — архитектура пофайловой обработки с одним ZIP (2026-07-13)
## Задача
DrHider (Flask + waitress). Ограничения:
- Фронтенд должен слать файлы **по одному** (сеть не пропускает большие multipart)
- Бэкенд должен обрабатывать **по одному** (без параллельных LLM-запросов)
- Результат: **один ZIP** со всеми обфусцированными .md + **один общий** mapping.csv
- **Никакой обработки данных в JS** — фронтенд только кнопки и скачивание
## Варианты
### А. Upload → Process
1. `POST /api/upload` — один файл → `{session:"abc"}`
2. Фронтенд циклом шлёт все, получает session
3. `POST /api/process/abc` — бэкенд обрабатывает всё → ZIP
4. Фронтенд скачивает ZIP
### Б. Один запрос, все файлы
1. Фронтенд: один POST, все файлы в FormData
2. Бэкенд: получил все → обработал по одному → ZIP
3. Waitress держит долгое соединение, таймаут 600с
### В. SSE с прогрессом
1. `POST /api/start``{session:"abc"}`
2. `POST /api/upload/abc` — по одному файлу
3. `GET /api/process/abc` — SSE стримит прогресс каждого файла и в конце ZIP
## Вопросы
1. Какой вариант правильный для Flask + waitress?
2. Вариант Б — пройдёт ли большой multipart через ingress (6 файлов × 30-60 KB)?
3. Если А — как чистить сессии? (пользователь закрыл вкладку, файлы остались в памяти)
4. Нужен ли SSE или достаточно простого XHR с таймером?
5. mapping.csv — генерировать на лету (по мере обработки) или после всех файлов?
## Сейчас работает (но сломано)
Сейчас: фронтенд шлёт по одному файлу, каждый получает отдельный ZIP. Пользователь хочет один ZIP в конце. JS обрабатывал ZIP'ы (распаковывал/перепаковывал) — это убрали, всё должно быть на бэкенде.
@@ -0,0 +1,17 @@
# Ответ Соннета — архитектура upload+process (2026-07-13)
## Рекомендация: Вариант А + блокирующий /process
```
POST /api/upload → {session_id} (фронт шлёт по одному, N раз)
POST /api/process/{sid} → {status:"done"} (блокирует до конца обработки)
GET /api/download/{sid} → ZIP
```
Без polling — `/process` просто блокирует XHR до готовности. Waitress держит.
## Детали
- Mapping.csv — на лету, аккумулируется после каждого файла
- Сессии — dict в памяти, TTL 30 минут через threading.Timer
- Фронт: цикл upload → process → download. Три простых XHR.
- Никакого JS-кода обработки данных.
+1 -1
View File
@@ -20,7 +20,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "6.0.0" VERSION = "7.0.0"
def create_app(): def create_app():
+60 -50
View File
@@ -1,73 +1,83 @@
""" """
Blueprint: API обфускации (POST /api/drhider). Blueprint: API DrHider.
Принимает multipart/form-data с файлами, возвращает ZIP-архив Three endpoints:
с обфусцированными документами. - POST /api/upload — upload one file -> {session_id}
- POST /api/process/{sid} — process all session files -> {status:"done"}
- GET /api/download/{sid} — download ZIP
""" """
import io import io
import zipfile
import traceback import traceback
from flask import Blueprint, request, send_file, jsonify from flask import Blueprint, request, send_file, jsonify
from drhider import obfuscate_files, LLMClient from drhider import obfuscate_files, LLMClient
from drhider.builder import build_zip, build_mapping_csv
# ═══════════════════════════════════════════════════════════════════════════ from session import create_session, add_file, get_files, store_result, get_result, cleanup, file_count
# Blueprint: API DrHider
# ═══════════════════════════════════════════════════════════════════════════
api_bp = Blueprint("api", __name__, url_prefix="/api") api_bp = Blueprint("api", __name__, url_prefix="/api")
# Максимальный размер тела запроса: 200 MB
MAX_BODY = 200 * 1024 * 1024
@api_bp.route("/upload", methods=["POST"])
@api_bp.route("/drhider", methods=["POST"]) def upload():
def drhider(): """Upload one file to session."""
"""Обфускация документов. sid = request.form.get("session", "")
if not sid:
Принимает multipart/form-data с полем 'files' (один или несколько файлов). sid = create_session()
Возвращает ZIP-архив с обфусцированными документами + mapping.csv.
Поддерживаемые форматы:
.docx, .pdf, .txt, .doc (бинарный — без изменений)
.zip (распаковывается, содержимое обфусцируется)
Returns:
200: ZIP-архив (application/zip)
400: {"ok": false, "error": "..."}
500: {"ok": false, "error": "..."}
"""
# ── Получаем файлы из запроса ──
uploaded = request.files.getlist("files") uploaded = request.files.getlist("files")
if not uploaded: if not uploaded:
return jsonify({"ok": False, "error": "Нет файлов"}), 400 return jsonify({"ok": False, "error": "No file"}), 400
f = uploaded[0]
if not f.filename:
return jsonify({"ok": False, "error": "No filename"}), 400
ok = add_file(sid, f.filename, f.read())
if not ok:
return jsonify({"ok": False, "error": "Session not found"}), 404
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
# Формируем список для obfuscate_files: [(name, bytes, mimetype), ...]
files = []
for f in uploaded:
if f.filename:
files.append((f.filename, f.read(), f.mimetype or ""))
@api_bp.route("/process/<sid>", methods=["POST"])
def process(sid):
"""Process all session files one by one -> ZIP."""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
if not files: if not files:
return jsonify({"ok": False, "error": "Нет файлов"}), 400 return jsonify({"ok": False, "error": "No files"}), 400
# ── Обфускация ──
try: try:
# Создаём LLM-клиент для NER-сканирования
llm = LLMClient() llm = LLMClient()
all_results = []
# Вызываем ядро обфускации all_mapping = {}
zip_data, _csv = obfuscate_files(files, llm_client=llm) for fname, content in files:
zip_data, _csv = obfuscate_files([(fname, content, "")], llm_client=llm)
# Отдаём ZIP-архив with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
return send_file( for name in zf.namelist():
io.BytesIO(zip_data), if name == "mapping.csv":
mimetype="application/zip", csv_text = zf.read(name).decode("utf-8")
as_attachment=True, lines = csv_text.strip().split("\n")
download_name="drhider_output.zip", if len(lines) > 1:
) for line in lines[1:]:
parts = line.split(",", 2)
if len(parts) >= 3:
all_mapping[parts[1]] = parts[2]
elif not name.endswith("/"):
all_results.append((name, zf.read(name)))
csv_str = build_mapping_csv(all_mapping) if all_mapping else ""
final_zip = build_zip(all_results, mapping_csv=csv_str)
store_result(sid, final_zip)
return jsonify({"ok": True, "status": "done", "files": len(all_results)})
except Exception as e: except Exception as e:
# Логируем полный трейсбек на сервере
traceback.print_exc() traceback.print_exc()
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": False, "error": str(e)}), 500
@api_bp.route("/download/<sid>", methods=["GET"])
def download(sid):
"""Download result and cleanup session."""
zip_data = get_result(sid)
if zip_data is None:
return jsonify({"ok": False, "error": "Not found"}), 404
cleanup(sid)
return send_file(io.BytesIO(zip_data), mimetype="application/zip",
as_attachment=True, download_name="drhider_output.zip")
+138
View File
@@ -0,0 +1,138 @@
"""
Хранилище сессий в памяти с автоочисткой по TTL.
Использование:
sid = create_session()
add_file(sid, filename, content_bytes)
files = get_files(sid)
store_result(sid, zip_bytes)
zip_data = get_result(sid)
cleanup(sid)
"""
import uuid
import threading
import time
from typing import Dict, List, Tuple, Optional
# ═══════════════════════════════════════════════════════════════════════════
# Данные сессии
# ═══════════════════════════════════════════════════════════════════════════
TTL_SECONDS = 30 * 60 # 30 минут
_sessions: Dict[str, dict] = {}
_lock = threading.Lock()
def _start_timer(sid: str):
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
# ═══════════════════════════════════════════════════════════════════════════
# API
# ═══════════════════════════════════════════════════════════════════════════
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex[:12]
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"timer": _start_timer(sid),
}
return sid
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если сессия существует, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["files"].append((filename, content))
return True
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Args:
sid: Идентификатор сессии
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки.
Args:
sid: Идентификатор сессии
zip_data: ZIP-архив с результатом
Returns:
True если сессия существует, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Args:
sid: Идентификатор сессии
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
def cleanup(sid: str):
"""Удалить сессию."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
+44 -33
View File
@@ -155,51 +155,62 @@ async function uploadFiles() {
if (sf.length === 0) return; if (sf.length === 0) return;
ub.disabled = true; ub.disabled = true;
const total = sf.length; const total = sf.length;
let ok = 0, fail = 0; let sid = '';
// Фаза 1: загрузка всех файлов
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
const f = sf[i], n = i + 1; const f = sf[i], n = i + 1;
const t0 = performance.now(); ss(i, '<span style="color:#2563eb">⏳ загрузка</span>');
const timer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
ss(i, '<span style="color:#2563eb">⏳ ' + sec + 'с</span>');
}, 200);
st.className = 'status progress'; st.className = 'status progress';
st.textContent = 'Файл ' + n + '/' + total + ': ' + f.name; st.textContent = 'Загрузка ' + n + '/' + total + ': ' + f.name;
const fd = new FormData(); const fd = new FormData();
fd.append('files', f, f.name); fd.append('files', f, f.name);
if (sid) fd.append('session', sid);
try { try {
const blob = await new Promise((resolve, reject) => { const resp = await fetch('/api/upload', { method: 'POST', body: fd });
const x = new XMLHttpRequest(); const data = await resp.json();
x.open('POST', '/api/drhider'); if (!data.ok) throw new Error(data.error);
x.responseType = 'blob'; sid = data.session;
x.timeout = 600000; ss(i, '<span style="color:#22c55e">✓ загружен</span>');
x.onload = () => x.status === 200 ? resolve(x.response) : reject(new Error('Сервер ' + x.status));
x.onerror = () => reject(new Error('Сеть'));
x.ontimeout = () => reject(new Error('Таймаут'));
x.send(fd);
});
clearInterval(timer);
const el = ((performance.now() - t0) / 1000).toFixed(1);
// Скачиваем ZIP как есть — бэкенд всё сделал
const u = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = u;
a.download = f.name.replace(/\.[^.]+$/, '') + '_obfuscated.zip';
a.click();
URL.revokeObjectURL(u);
ss(i, '<span style="color:#22c55e">✓ ' + el + 'с</span>');
ok++;
} catch (err) { } catch (err) {
clearInterval(timer);
ss(i, '<span style="color:#ef4444">✗</span>'); ss(i, '<span style="color:#ef4444">✗</span>');
fail++; st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message;
ub.disabled = false;
return;
} }
if (i < total - 1) await new Promise(r => setTimeout(r, 300));
} }
st.className = fail === 0 ? 'status done' : 'status error'; // Фаза 2: обработка (один запрос, ждём)
st.textContent = fail === 0 ? '✅ Готово! ' + ok + '/' + total : 'Готово: ' + ok + ' OK, ' + fail + ' ошибок из ' + total; for (let i = 0; i < total; i++) ss(i, '<span style="color:#2563eb">⏳</span>');
st.className = 'status progress';
st.textContent = 'Обработка...';
const t0 = performance.now();
const ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка... ' + sec + 'с';
}, 1000);
try {
const resp = await fetch('/api/process/' + sid, { method: 'POST' });
const data = await resp.json();
clearInterval(ptimer);
if (!data.ok) throw new Error(data.error);
st.className = 'status done';
st.textContent = '✅ Обработано ' + data.files + ' файлов за ' + ((performance.now() - t0) / 1000).toFixed(1) + 'с';
} catch (err) {
clearInterval(ptimer);
st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message;
ub.disabled = false;
return;
}
// Фаза 3: скачивание
const a = document.createElement('a');
a.href = '/api/download/' + sid;
a.download = 'drhider_output.zip';
a.click();
ub.disabled = false; ub.disabled = false;
} }
</script> </script>