Files
drhider/PLAN-upload-layers.md
T

291 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ПЛАН: вынос 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, не кода слоя).