docs: детализировать PLAN-upload-layers (2 слоя, файл-на-функцию, README для агента)

This commit is contained in:
“Naeel”
2026-08-25 07:57:55 +03:00
parent 7a4f642452
commit a18877415d
+290
View File
@@ -0,0 +1,290 @@
# ПЛАН: вынос 2 слоёв загрузки-через-ВМ в переиспользуемый модуль
> Для Flash. Источник-референс: drhider v0.0.75 (`site/templates/index.html`,
> `site/routes/api_bp.py`, `site/session.py`). Цель — **1:1** по поведению, но в виде
> **переиспользуемого программного слоя ВНУТРИ приложения** (не отдельного сервиса):
> его встраивает и drhider, и любые другие проекты.
---
## Требования (согласовано с владельцем)
1. **2 слоя структурно выделены** — их можно взять и перенести в другой проект
**без изменения кода** (только свой конфиг).
2. **Без общей репы** — модуль = подпапка внутри проекта, перенос копированием.
3. **Не мудрить** — главное: агент в новом проекте за 2 минуты понял, как интегрировать
(для этого короткий `README.md` внутри модуля).
4. **НЕ поломать текущий рабочий код drhider** — это рефакторинг 1:1 (extract, не rewrite).
5. **Файлы мелкие** — каждая функция в отдельном файле.
---
## 0. Контекст (что уже проверено в drhider)
Паттерн загрузки (шлюз managed-кластера рвёт тела >64КБ, egress не ограничен):
браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` (pull) → сессия.
Что НЕЛЬЗЯ потерять при переносе (накоплено и отлажено):
- дедуп «имя+размер», иначе суффикс `_2`;
- раскрытие ZIP с путями, фильтр расширений (`.pdf .doc .docx .txt .md`);
- рекурсивный обход папки (`webkitdirectory`), путь в таблице;
- **SSRF-валидация** URL (`url.startswith(VM_UPLOAD_PREFIX)`);
- **`_safe_name`** (path traversal, сохраняет подпапки);
- **ретраи pull** (3 попытки, пауза 2 с);
- лимиты 50 МБ/файл, 500 МБ/сессия;
- различение «сессия не найдена» vs «лимит»;
- статусы «пропущен (лимит)» / «не извлечён»;
- `esc()` для имён (self-XSS).
---
## 1. Граница слоёв
| Слой | Где | Ответственность | drhider-источник |
|---|---|---|---|
| **0. Конфиг** | общий | `VM_UPLOAD_URL`, `allowedExt`, лимиты | константы в `index.html`/`api_bp.py` |
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки | `index.html` |
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия | `index.html` + `api_bp.py` + `session.py` |
| **3. Логика приложения** | бэк | обфускация/обработка, SSE-прогресс | `drhider/*`, `process_stream`**НЕ выносим** |
**Слой 3 НЕ выносим** — он у каждого приложения свой. В drhider остаётся: `drhider/*`
(extractor/scanner/replacer/builder/obfuscator), `process_stream`, `process`, `download`,
`csv_download`, `cancel`.
**Всё, что у drhider своё (URL, расширения, лимиты) — в конфиг (слой 0), а не в код слоя.**
---
## 2. Структура модуля (каждая функция — отдельный файл)
```
upload/
README.md # ← главное: как интегрировать (для агента)
config.example.json # слой 0: образец конфига
frontend/
zip/ # распаковка ZIP (чистые функции, без DOM)
dos_to_ms.js # dosToMs(date, time)
decode_zip_name.js # decodeZipName(bytes, isUtf8)
inflate_raw.js # inflateRaw(bytes)
parse_zip.js # parseZip(buf)
list_zip_files.js # listZipFiles(file) — рекурсия по вложенным zip
table/ # слой 1: выбор файлов
fs.js # fs(b) — формат размера
fmt_sec.js # fmtSec(s) — формат секунд
esc.js # esc(s) — экранирование HTML
est_for_file.js # estForFile(f) + EST_MB_SEC (из конфига)
add_file_with_dedup.js # addFileWithDedup(file)
render.js # rr() — обычная таблица
proc_row.js # procRow(i, stTxt)
render_proc_table.js # renderProcTable() — 3-секционная таблица
rm_file.js # rm(i) — удаление строки
set_status.js # ss(idx, h) — статус в ячейке
on_files_change.js # обработчик <input type=file> change
on_folder_change.js # обработчик webkitdirectory change
init_upload_table.js # initUploadTable(cfg) — сборка слоя 1 (состояние внутри)
upload/ # слой 2 (фронт): закачка через ВМ
put_to_vm.js # PUT одного файла (XHR, progress → setStatus)
upload_via_vm.js # uploadViaVM(files, vmUploadUrl) — цикл PUT + POST upload_refs
backend/
upload_refs/ # слой 2 (бэк): pull + валидация
__init__.py
config.py # PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX (из конфига)
safe_name.py # _safe_name(name)
pull_file.py # pull с ретраями (3×2с, stream, лимит 50МБ)
blueprint.py # create_upload_refs_blueprint(cfg) → Blueprint
session/
__init__.py
state.py # _sessions, _lock, _start_timer (общее состояние)
create_session.py # create_session()
add_file.py # add_file(sid, name, content)
get_files.py # get_files(sid), file_count(sid)
store_result.py # store_result(sid, zip), get_result(sid)
store_csv.py # store_csv(sid, csv), get_csv(sid)
ttl.py # touch, pause_ttl, resume_ttl
cancel.py # request_cancel, get_cancel_event
cleanup.py # cleanup(sid)
```
> Примечание по `backend/session/`: там **общее глобальное состояние** (`_sessions`, `_lock`).
> Поэтому `state.py` выделен отдельно, а каждая операция — свой файл, импортирующий `state`.
> Дробить `state.py` на файл-на-переменную не нужно — это данные, а не функции.
---
## 3. Маппинг: что откуда взять (v0.0.75, имена функций точные)
Переносится **без изменения логики**. Меняется только способ обращения:
глобальные переменные → параметры/состояние модуля.
### Фронт (`site/templates/index.html`)
| Функция в drhider | → файл модуля | Что меняется при переносе |
|---|---|---|
| `dosToMs` | `zip/dos_to_ms.js` | ничего (чистая) |
| `decodeZipName` | `zip/decode_zip_name.js` | ничего |
| `inflateRaw` | `zip/inflate_raw.js` | ничего |
| `parseZip` | `zip/parse_zip.js` | ничего |
| `listZipFiles` | `zip/list_zip_files.js` | `allowedExt` → параметр (сейчас захардкожен внутри) |
| `fs` | `table/fs.js` | ничего |
| `fmtSec` | `table/fmt_sec.js` | ничего |
| `esc` | `table/esc.js` | ничего |
| `estForFile` | `table/est_for_file.js` | `EST_MB_SEC` → конфиг |
| `addFileWithDedup` | `table/add_file_with_dedup.js` | `MAX_FILE_BYTES`/`MAX_SESSION_BYTES` → конфиг; `sf`/`fileMeta`/`overNames` → состояние модуля |
| `rr` | `table/render.js` | доступ к `sf`/`overNames` через состояние |
| `procRow` | `table/proc_row.js` | ничего (чистая отрисовка) |
| `renderProcTable` | `table/render_proc_table.js` | `procState`/`procExtractRate` → параметры/состояние |
| `rm` | `table/rm_file.js` | `busy`, `fi.files` → состояние/колбэк |
| `ss` | `table/set_status.js` | ничего |
| `fi` change handler | `table/on_files_change.js` | вызывается из `initUploadTable` |
| `folderInput` change handler | `table/on_folder_change.js` | вызывается из `initUploadTable` |
| PUT-цикл из `uploadFiles()` | `upload/put_to_vm.js` | `VM_UPLOAD_URL` → параметр; `ss` → колбэк прогресса |
| `uploadFiles()` фазы 1+1b | `upload/upload_via_vm.js` | **только** PUT + `POST upload_refs`; SSE-часть остаётся в drhider |
> **Граница слоя 2/3:** `uploadFiles()` сейчас смешанный — в нём и закачка (слой 2), и SSE (слой 3).
> При выносе разделить: `uploadViaVM(files, vmUploadUrl)` делает PUT + POST `/api/upload_refs`
> и возвращает `{ok, session, count}`; drhider после этого запускает **свою** SSE-обработку.
### Бэк (`site/routes/api_bp.py`, `site/session.py`)
| Сейчас | → модуль | Что меняется |
|---|---|---|
| `PULL_RETRIES`, `PULL_RETRY_DELAY`, `VM_UPLOAD_PREFIX` | `upload_refs/config.py` | читаются из конфига |
| `_safe_name` | `upload_refs/safe_name.py` | ничего |
| тело `upload_refs` (цикл pull) | `upload_refs/pull_file.py` | вынести ретраи/стрим/лимит в `pull_file(client, url)` |
| endpoint `upload_refs` | `upload_refs/blueprint.py` | `create_upload_refs_blueprint(cfg)` — Blueprint с префиксом из конфига |
| `session.py` целиком | `backend/session/*` | дробление на файл-на-функцию (1:1) |
`session.py` переносится **1:1** — это самодостаточный модуль, просто дробится на функции.
---
## 4. Интерфейсы (точные сигнатуры)
### Слой 1 — `initUploadTable(cfg) -> api`
```
cfg = {
allowedExt: ['.pdf','.doc','.docx','.txt','.md'],
maxFileBytes: 50*1024*1024,
maxSessionBytes: 500*1024*1024,
fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl,
onFilesChanged(list) // колбэк
}
api = {
pickFiles(), pickFolder(), // привязка к <input>
addFiles(File[]), // дедуп+раскрытие zip+фильтр+лимиты
getFiles() -> [{name,size,file}], // ТОЛЬКО учитываемые (без over)
getOverNames() -> Set, // сверх лимита
setStatus(name, html), // статус в ячейке
render(), clear()
}
```
### Слой 2 — фронт `uploadViaVM(files, vmUploadUrl)`
```
uploadViaVM(files, vmUploadUrl) -> Promise<{ok, session, count, error?}>
1. token = crypto.randomUUID()
2. для каждого файла: PUT vmUploadUrl + token + '_' + k (XHR, progress -> setStatus)
refs.push({name, size, url})
3. POST /api/upload_refs {session, files: refs}
-> {ok, session, count} | {ok:false, error}
```
### Слой 2 — бэк `create_upload_refs_blueprint(cfg)`
```
POST /api/upload_refs {session?, files:[{name,size,url}]}
- name = _safe_name(name) (path traversal)
- url.startswith(VM_UPLOAD_PREFIX) (SSRF)
- size > 50МБ -> delete+skip
- pull: 3 ретрая, пауза 2с
- len(content) > 50МБ -> delete+skip
- add_file: нет сессии -> 404; лимит 500МБ -> skip
- delete url (best-effort)
-> {ok, session, count}
```
---
## 5. Конфиг (слой 0)
```json
{
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
"maxFileBytes": 52428800,
"maxSessionBytes": 524288000,
"apiPrefix": "/api",
"pullRetries": 3,
"pullRetryDelay": 2
}
```
Всё, что отличает drhider от другого проекта, задаётся **здесь**. Код слоёв не содержит
ни одного drhider-специфичного значения.
---
## 6. Порядок работы для Flash (по шагам, после каждого — проверка)
Строго последовательно, чтобы не сломать прод:
1. **`zip/*`** — вынести 5 чистых функций, написать юнит-тест (кириллица, вложенный zip,
не-doc расширения). Самый изолированный кусок — безопасен.
2. **`table/*`** (слой 1) — вынести функции таблицы/выбора, состояние `sf`/`fileMeta`/`overNames`
увести внутрь `init_upload_table.js`.
3. **`upload/*`** (слой 2 фронт) — PUT + `upload_refs`, параметризовать `vmUploadUrl`.
4. **`backend/upload_refs/*`** + **`backend/session/*`** — вынести `_safe_name`, pull, Blueprint, сессию.
5. **Подключить модуль обратно в drhider** — заменить встроенный код вызовами модуля,
**по одному куску**, прогоняя тесты после каждого. Слой 3 не трогать.
6. **`README.md`** модуля — короткая инструкция для агента нового проекта (раздел 8).
---
## 7. Обязательный чек-лист приёмки (без этого «1:1» не достичь)
- [ ] дедуп имя+размер, суффикс `_2` при другом размере;
- [ ] рекурсивный обход папки, путь (в т.ч. из архива) в таблице;
- [ ] раскрытие ZIP только документов, вложенные ZIP;
- [ ] SSRF-валидация URL, `_safe_name`, ретраи pull — на месте;
- [ ] лимиты 50/500 МБ, статусы «пропущен (лимит)» и «не извлечён»;
- [ ] `esc()` имён (нет XSS);
- [ ] кнопка загрузки disabled при 0 файлов;
- [ ] заморозка UI во время загрузки/обработки;
- [ ] прогресс загрузки (PUT) в таблице;
- [ ] поведение drhider на проде не изменилось.
---
## 8. Что писать в `README.md` модуля (главное для агента нового проекта)
1. **Что это** — 2 слоя и их ответственность (таблицей из раздела 1).
2. **Как подключить фронт** — подключить `zip/*`, `table/*`, `upload/*` как ES-модули
(`<script type="module">`), вызвать `initUploadTable(cfg)` и `uploadViaVM(...)`.
3. **Как подключить бэк** — зарегистрировать `create_upload_refs_blueprint(cfg)` в приложении,
импортировать `session` (create/add/get).
4. **Какой конфиг задать** — один блок `config.example.json` с пояснением каждого поля.
5. **Что НЕ трогать** — слой 3 (логика приложения у каждого своя).
---
## 9. Что НЕ менять в drhider (риск регресса)
- `drhider/*` (extractor/scanner/replacer/builder/obfuscator) — слой 3, не трогать.
- `process_stream` (SSE), `process`, `download`, `csv_download`, `cancel` — слой 3.
- SSE-часть `uploadFiles()`, `downloadZip()`, `downloadCsv()`, `resetAll()`, `setBusy()`,
`finishProcUI()` — приложение drhider.
- Рефакторить drhider под модуль — только после того, как модуль заработает на новом сервисе.
---
## 10. Открытое решение (спросить у владельца)
- **Куда класть модуль в новом проекте**: папка `upload/` внутри проекта (согласовано —
без общей репы). CORS на ВМ уже настроен под существующий origin — менять только если
реально другой домен (это правка nginx, не кода слоя).