From 82d9ca913699fd5b7e7c32265757b82ef7f41685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sat, 5 Sep 2026 08:09:05 +0300 Subject: [PATCH] Add README and implementation plan --- PLAN.md | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 65 +++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 PLAN.md create mode 100644 README.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..b04429c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,275 @@ +# Upload Platform — подробный план реализации + +> Целевой исполнитель: Flash / GPT-5.6 Luna. План самодостаточен: читай и кодируй по нему. +> Источник-референс (паттерн уже отлажен): drhider v0.0.75 (`site/templates/index.html`, +> `site/routes/api_bp.py`, `site/session.py`, `upload/`). Здесь — **standalone** проект: +> те же 2 слоя, но вынесены в отдельный репозиторий и демонстрируются на минимальной обёртке. + +--- + +## 0. Цель и границы + +**Что делаем:** отдельный переиспользуемый сервис загрузки файлов через ВМ-буфер. +Паттерн: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` (egress pull) → сессия. + +**Что входит (переиспользуемое):** +- Слой 1 — выбор файлов (фронт): таблица/дерево, дедуп, раскрытие ZIP, рекурсивный обход папки. +- Слой 2 — закачка через ВМ (фронт + бэк): PUT, pull, сессия, лимиты, валидации. + +**Что НЕ входит (у каждого приложения своё):** +- Слой 3 — логика приложения (обфускация/обработка/SSE-прогресс). Здесь — только **стаб-заглушка**, + которая показывает, что файлы доехали до сессии (список + размеры), без реальной обработки. + +**Жёсткие требования:** +1. Vanilla JS, без React/сборщика. Единственная новая зависимость — `fflate` (CDN) для ZIP. +2. Каждая функция — отдельный файл (мелкие файлы). +3. Всё, что отличает один проект от другого — **в конфиге**, не в коде слоёв. +4. Безопасность сохранить 1:1: SSRF-валидация URL, `_safe_name` (path traversal), ретраи pull, + лимиты, `esc()` имён (self-XSS). +5. Запуск на Nubes: структура `site/app.py`, `python site/app.py`, `0.0.0.0:5000`, + **маршрут `/health` → 200** (иначе liveness-проба платформы убивает под), `debug=False`. + +--- + +## 1. Архитектура (слои) + +| Слой | Где | Ответственность | +|---|---|---| +| **0. Конфиг** | общий | `vmUploadUrl`, `allowedExt`, лимиты, ретраи | +| **1. Выбор файлов** | фронт | таблица/дерево, дедуп, раскрытие ZIP, папка, путь, статусы, кнопки | +| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ (фронт) → `upload_refs` pull (бэк) → сессия | +| **3. Логика приложения** | бэк | **стаб** (в реальном проекте своя — сюда приходит SSE/обработка) | + +**Слой 3 здесь — заглушка.** Он НЕ переиспользуется. Его задача — доказать, что слой 2 +корректно передаёт файлы в сессию и что в новом проекте к нему легко прицепить свою логику. + +--- + +## 2. Структура проекта (обязательная) + +``` +upload-platform/ +├── requirements.txt +├── README.md # что это + как интегрировать слои в другой проект +├── PLAN.md # этот план +├── config.json # слой 0 (рабочий конфиг ЭТОГО демо) +├── upload/ # ← переиспользуемый модуль (копируется в любой проект) +│ ├── README.md # короткая инструкция интеграции (для агента) +│ ├── config.example.json # образец конфига +│ ├── frontend/ +│ │ ├── zip/ +│ │ │ └── list_zip_files.js # listZipFiles(file, allowedExt) на fflate +│ │ ├── table/ # слой 1 +│ │ │ ├── fs.js +│ │ │ ├── esc.js +│ │ │ ├── add_file_with_dedup.js +│ │ │ ├── render.js +│ │ │ ├── set_status.js +│ │ │ ├── on_files_change.js +│ │ │ ├── on_folder_change.js +│ │ │ └── init_upload_table.js +│ │ └── upload/ # слой 2 (фронт) +│ │ ├── put_to_vm.js +│ │ └── upload_via_vm.js +│ └── backend/ +│ ├── upload_refs/ # слой 2 (бэк) +│ │ ├── __init__.py +│ │ ├── config.py +│ │ ├── safe_name.py +│ │ ├── pull_file.py +│ │ └── blueprint.py +│ └── session/ +│ ├── __init__.py +│ ├── state.py +│ ├── create_session.py +│ ├── add_file.py +│ ├── get_files.py +│ └── cleanup.py +└── site/ # демо-обёртка (НЕ переиспользуется) + ├── app.py # точка входа, / и /health + ├── routes/ + │ └── api_bp.py # регистрирует blueprint из upload/ + стаб слоя 3 + ├── templates/ + │ └── index.html # слой 1 + 2 (подключает ES-модули из upload/frontend) + └── static/ + ├── style.css + └── vendor/fflate.min.js # fflate (локально, не CDN — надёжнее) +``` + +> ⛔ `site/__init__.py` **НЕ создавать** — конфликтует со stdlib `site.py` (Nubes HowTo). +> ⛔ Импорты бэка — всегда без префикса `site.` (запуск `python site/app.py` добавляет `site/` в path). +> ⛔ Без factory pattern — платформа ждёт `app` на уровне модуля. + +--- + +## 3. Слой 0 — конфиг (`config.json` / `config.example.json`) + +```json +{ + "vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/", + "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"], + "maxFileBytes": 52428800, + "maxSessionBytes": 524288000, + "apiPrefix": "/api", + "pullRetries": 3, + "pullRetryDelay": 2 +} +``` + +Каждое поле: +- `vmUploadUrl` — префикс ВМ-буфера (для PUT и SSRF-валидации pull). +- `allowedExt` — допустимые расширения (фильтр и в ZIP, и в папке). +- `maxFileBytes` / `maxSessionBytes` — лимиты. +- `apiPrefix` — префикс маршрутов бэка. +- `pullRetries` / `pullRetryDelay` — ретраи pull (защита от разовых DNS/сетевых сбоев). + +В бэке конфиг читается из `os.getenv` (на Nubes — через `jsonEnv`), значения по умолчанию — +из `config.example.json`. Никаких drhider-специфичных значений в коде слоёв. + +--- + +## 4. Слой 1 — выбор файлов (фронт) + +### Поведение (1:1 с drhider) +- Выбор файлов (``). +- Выбор папки (`webkitdirectory`) → рекурсивный обход, в таблице виден относительный путь. +- `.zip` → раскрытие через `fflate.unzip` **с `filter` по `allowedExt`** (не распаковывать + не-doc), рекурсивно по вложенным `.zip`; путь в таблице — `Папка/архив.zip/Внутри/файл.txt`. +- Дедуп по «путь+размер»; имя совпало, размер другой → суффикс `_2`. +- Лимиты: файл > `maxFileBytes` → «не учитывается»; сумма учитываемых > `maxSessionBytes` → тоже. +- Имена экранируются через `esc()` (self-XSS). +- Кнопка загрузки disabled при 0 учитываемых файлов; UI замораживается во время загрузки. + +### Интерфейс `initUploadTable(cfg) -> api` + +``` +cfg = { + allowedExt, maxFileBytes, maxSessionBytes, + fileInputEl, folderInputEl, tableBodyEl, countEl, uploadBtnEl, + onFilesChanged(list) +} +api = { + pickFiles(), pickFolder(), + addFiles(File[]), // дедуп + раскрытие zip + фильтр + лимиты + getFiles() -> [{path,name,size,file}], // ТОЛЬКО учитываемые + getOverNames() -> Set, + setStatus(path, html), + render(), clear() +} +``` + +Состояние (`sf`, `fileMeta`, `overNames`) — внутри `init_upload_table.js`, наружу не торчит. + +### Файлы и их функции +| Файл | Функция | Примечание | +|---|---|---| +| `zip/list_zip_files.js` | `listZipFiles(file, allowedExt)` | `fflate.unzip` + filter + рекурсия | +| `table/fs.js` | `fs(b)` | формат размера | +| `table/esc.js` | `esc(s)` | экранирование HTML | +| `table/add_file_with_dedup.js` | `addFileWithDedup(file)` | дедуп + лимиты | +| `table/render.js` | `render()` | обычная таблица | +| `table/set_status.js` | `setStatus(path, html)` | статус в ячейке | +| `table/on_files_change.js` | обработчик file input | | +| `table/on_folder_change.js` | обработчик webkitdirectory | | +| `table/init_upload_table.js` | `initUploadTable(cfg)` | сборка слоя 1 | + +> Дерево (сворачивание по папкам/архивам) — **фаза 2**, см. `PLAN-file-picker.md` (в drhider). +> В первой итерации — плоская таблица с колонкой «путь», как сейчас в drhider. + +--- + +## 5. Слой 2 — закачка через ВМ + +### Фронт: `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} +``` + +- `put_to_vm.js` — `putToVm(file, url, onProgress)` (XHR, сырое тело, прогресс в ячейку). +- `upload_via_vm.js` — `uploadViaVM(files, vmUploadUrl)`. +- SSE-часть (слой 3) сюда НЕ входит — возвращаем `{ok, session, count}`, дальше приложение само. + +### Бэк: `create_upload_refs_blueprint(cfg) -> Blueprint` + +``` +POST /api/upload_refs {session?, files:[{name,size,url}]} + - name = _safe_name(name) (path traversal) + - url.startswith(vmUploadUrl) (SSRF) + - size > maxFileBytes -> delete+skip + - pull: pullRetries × (пауза pullRetryDelay), stream, лимит maxFileBytes + - len(content) > maxFileBytes -> delete+skip + - add_file: нет сессии -> 404; лимит сессии -> skip + - delete url (best-effort) + -> {ok, session, count} +``` + +- `upload_refs/config.py` — ретраи/префикс из конфига. +- `upload_refs/safe_name.py` — `safe_name(name)` (path traversal, сохраняет подпапки). +- `upload_refs/pull_file.py` — `pull_file(client, url)` (ретраи, stream, лимит). +- `upload_refs/blueprint.py` — `create_upload_refs_blueprint(cfg)`. + +### Сессия (`backend/session/`) +Самодостаточный модуль, дробится на файл-на-функцию. Общее состояние (`_sessions`, `_lock`) — в +`state.py`; операции импортируют `state`. + +- `create_session.py` — `create_session()` +- `add_file.py` — `add_file(sid, name, content)` → bool (лимит) +- `get_files.py` — `get_files(sid)`, `file_count(sid)` +- `cleanup.py` — `cleanup(sid)` +- (при необходимости позже: `store_result`, `store_csv`, `ttl`, `cancel`) + +--- + +## 6. Слой 3 — стаб (демо-обёртка `site/`) + +- `site/app.py` — `app = Flask(...)`, `@app.route("/")` → `index.html`, `@app.route("/health")` → `"ok", 200`, + `if __name__ == "__main__": app.run(debug=False, host="0.0.0.0", port=5000)`. +- `site/routes/api_bp.py` — регистрирует `create_upload_refs_blueprint(cfg)`, добавляет демо-маршрут + `POST /api/process` → возвращает список файлов сессии (имя + размер) — **доказательство**, что + слой 2 довёз файлы; в реальном проекте здесь будет своя обработка. +- `site/templates/index.html` — `