25 changed files with 743 additions and 218 deletions
@@ -0,0 +1,84 @@
# Ревью Gemini 3.8 Flash — 2026-09-05
## Контекст
Ревью ограничивалось проектом `upload-platform`. Проверены frontend-модули
универсального browser-only picker-а, Flask-обёртка, конфигурация и структура
проекта. Ниже зафиксированы findings ревью; исправления по этим пунктам в рамках
данной записи не выполнялись.
## Критические ошибки и логические баги (High)
1. **Не сбрасывается `fileInputEl.value` при выборе файлов.**
**Файл:** `upload/frontend/table/on_files_change.js`.
После удаления файла повторный выбор того же файла может не вызвать событие
`change`, если значение input не сброшено.
2. **Распаковка лишних файлов и преждевременный OOM в ZIP-фильтре.**
**Файл:** `upload/frontend/zip/list_zip_files.js`.
Callback `filter` проверяет размер и бюджет, но до декомпрессии не отбрасывает
записи с неразрешёнными расширениями. Бинарные файлы могут быть распакованы в
память, хотя затем будут отброшены.
3. **Синхронная блокировка UI при распаковке архивов.**
**Файл:** `upload/frontend/zip/list_zip_files.js`.
`unzipSync` выполняется в главном потоке браузера и может замораживать UI на
больших архивах. Предложенное направление: async unzip с чанками или Web Worker.
## Архитектурные недочёты и надёжность (Medium)
4. **Нарушается семантика `File.name` для файлов из папок и ZIP.**
**Файлы:** `upload/frontend/table/rebase_tree.js`,
`upload/frontend/zip/list_zip_files.js`.
При создании нового `File` полный логический путь может попасть в `file.name`,
хотя путь должен храниться отдельно в метаданных узла.
5. **Не выполняется слияние папок с одинаковыми путями.**
**Файл:** `upload/frontend/table/add_file_with_dedup.js`.
Повторное добавление корневого узла с тем же путём может создать два отдельных
узла вместо объединения их дочерних элементов.
6. **Потенциально небезопасная вставка HTML в статус.**
**Файл:** `upload/frontend/table/set_status.js`.
Прямое присваивание `innerHTML` требует гарантировать экранирование всех
передаваемых значений либо заменить его на безопасную работу с текстом.
7. **Молчаливый пропуск ошибок ZIP без `cfg.onError`.**
**Файлы:** `upload/frontend/table/on_files_change.js`,
`upload/frontend/table/on_folder_change.js`.
При отсутствии callback интегратор может не получить индикацию ошибки
повреждённого архива или превышения лимита.
8. **Падение `app.py` при отсутствии или повреждении `config.json`.**
**Файл:** `site/app.py`.
Чтение конфигурации выполняется на уровне импорта без обработки исключений;
при ошибке Flask-приложение не запускается.
## Мёртвый код и гигиена (Low)
9. **Дубликат `init_upload_table.js`.**
Файл выглядит неиспользуемым и дублирует логику текущего picker API.
Предложенное направление: удалить либо явно пометить как deprecated после
проверки всех импортов.
10. **Пустые служебные каталоги backend.**
Каталоги `session` и `upload_refs` содержат только старые `__pycache__`.
Требуется отдельное решение о сохранении или очистке структуры.
11. **Недостаточная обработка ошибок сборки в `build.mjs`.**
**Файл:** `build.mjs`.
Предложено добавить явную обработку ошибки сборки с `process.exit(1)` и
определить поведение каталога `dist/` перед сборкой.
## Итог
Gemini 3.8 Flash создал 4 задачи: три high/medium направления по ZIP и
событиям выбора, а также дополнительный набор архитектурных и гигиенических
проверок. Эта запись фиксирует результаты ревью как backlog. Код проекта и
рабочая реализация picker-а в рамках документирования не изменялись.
## Проверка записи
- Файл ревью создан в `HISTORY/`.
- Исходники проекта не изменялись.
- Findings сохранены в исходной классификации High, Medium и Low.
@@ -0,0 +1,98 @@
# Продолжение вопросов и ответов по ревью Gemini 3.8 Flash — 2026-09-05
## Контекст
Зафиксирован второй блок ответов Gemini на дополнительные вопросы по ревью
`upload-platform`. Ответы уточняют границы ответственности picker-а, приоритеты
и декомпозицию findings. Код приложения в рамках документирования не изменялся.
## Уточнения
### №4: `File.name` и логический путь
Рекомендация Gemini: сохранять в `file.name` только базовое имя файла, а полный
логический путь передавать через `node.path` и контракт `getFiles()`.
Основание: стандартный browser `File` использует `name` как имя файла без
каталогов. Полный путь в `file.name` может некорректно обрабатываться при
`FormData.append('files', file)` и сторонними upload-библиотеками. Передача пути
третьим аргументом `FormData.append(name, file, filename)` устраняет проблему,
но перекладывает внутреннюю особенность picker-а на интегратора.
### №8: fallback для `config.json`
Пункт признан защитной мерой demo-сервера, а не production-риском библиотеки.
В целевом сценарии распространяется JavaScript bundle из `dist/`, а `site/app.py`
служит demo-обёрткой и не участвует в интеграции picker-а с host-системой. Поэтому
пункт не входит в обязательный pre-release топ-3.
### №3: порог `unzipSync`
Оценочный порог заметного фриза заявлен как 15–25 MiB сжатых данных или сотни
мелких XML/DOCX entries с суммарной распаковкой свыше 50 MiB. Для архивов до
10 MiB задержка обычно несущественна. Для целевого профиля офисных документов и
текущих лимитов переход на Web Worker признан преждевременной оптимизацией.
Приоритетнее сначала фильтровать расширения до декомпрессии и сохранять жёсткие
лимиты. Worker остаётся backlog для архивов порядка 100 MiB и более.
### №7: поведение без `onError`
Рекомендованный default — выводить ошибку в общий статус picker-а, например в
`elements.statusEl` или `.fp-status`. Молчаливый `continue` создаёт плохой UX:
пользователь не понимает, почему содержимое архива не появилось. Создание
неотправляемого сломанного корневого узла в таблице также признано нежелательным.
### №10: состояние backend-каталогов
По заявленному результату проверки файловой системы в `upload/backend/` остаются
только каталоги `session/` и `upload_refs/`, внутри которых нет исходников; есть
лишь пустые каталоги `__pycache__` от удалённых модулей. Вердикт Gemini: чинить
нечего, это остатки, которые можно удалить отдельным разрешённым изменением.
## Декомпозиция 11 findings на 4 задачи
### Задача 1: pre-release hotfixes UI и ZIP
- №1: сброс `fileInputEl.value` после обработки выбора;
- №2: фильтрация расширения внутри ZIP `filter` до декомпрессии;
- №5: слияние корневых папок с одинаковым путём.
Результат: корректный повторный выбор, снижение риска OOM и отсутствие дубликатов
корневых папок.
### Задача 2: контракт данных и обработка ошибок
- №4: базовое имя в `file.name`, путь в метаданных;
- №7: fallback-вывод ошибок ZIP в общий статус;
- №8: безопасная загрузка `config.json` с fallback-конфигурацией.
Результат: предсказуемый контракт browser `File` и понятная обратная связь.
### Задача 3: очистка репозитория
- №6: удаление или отдельное решение по legacy `set_status.js`;
- №9: удаление или отдельное решение по legacy `init_upload_table.js`;
- №10: удаление пустого `backend/` с остатками `__pycache__`;
- №11: определение поведения `dist/` перед сборкой.
Результат: в репозитории остаются актуальные исходники и ясная структура сборки.
### Задача 4: производительность больших архивов
- №3: перевод тяжёлой ZIP-распаковки в async-поток или Web Worker.
Задача отнесена в backlog и актуальна при появлении сценариев с очень большими
архивами.
## Итоговый приоритет
До следующего release в первую очередь предлагаются пункты **№2, №1 и №5**.
Пункты №4, №7, №8 и очистка legacy-кода относятся ко второй очереди. №3
остаётся производительным backlog до подтверждения реальных сценариев больших
архивов.
## Статус документирования
- Ответ Gemini сохранён в `HISTORY/`.
- Исходный код picker-а не изменялся.
- Удаление `__pycache__` и любых каталогов не выполнялось.
+78
View File
@@ -0,0 +1,78 @@
# Вопросы и ответы по ревью Gemini 3.8 Flash — 2026-09-05
## Контекст
После критического разбора ревью Gemini 3.8 Flash были заданы уточняющие
вопросы по версии исходников, дублированию API, семантике `File.name`, legacy
XSS, `build.mjs`, ZIP-фильтрации и приоритетам исправлений.
## Зафиксированные ответы
### Версия кода и номера строк
Ревью выполнялось по исходникам `upload-platform`; `dist/` отдельно не
исследовался. Номера строк в первой сводке были смещены из-за объединения
отчётов субагента. Фактические участки находятся в текущих исходниках
`on_files_change.js` и `list_zip_files.js`.
### `init_upload_table.js`
Буквального совпадения интерфейса с `initFilePicker` нет: один entry point
принимает готовые DOM-узлы, другой создаёт DOM внутри `mount`. Однако внутренняя
логика жизненного цикла таблицы параллельна: состояние `nodes/fileKeys/busy`,
удаление узлов, делегирование кликов и похожий API. Файл не входит в граф сборки
`build.mjs`, поэтому это legacy-альтернатива, создающая риск рассинхронизации.
### `File.name` и логический путь
`flattenFiles()` возвращает `name`, `path` и `file` раздельно, но при `rebaseTree`
вложенный browser `File` получает полный путь в `file.name`. При прямом
`FormData.append('files', file)` сервер получает filename с разделителями пути.
Это может конфликтовать с серверными `basename`/`secure_filename` и нарушает
обычный контракт browser `File`, где `name` является базовым именем. При явной
передаче третьего аргумента `FormData.append('files', file, path)` проблема не
возникает.
### XSS в `set_status.js`
Замечание относится только к legacy-файлу. `set_status.js` не входит в активный
граф сборки и не используется боевым рендерером. В активном `render.js` значения
статуса экранируются через `esc()`. Реальная уязвимость в текущем активном
рендеринге не подтверждена; пункт относится к гигиене мёртвого кода.
### `build.mjs`
Для top-level `await` необработанная ошибка сборки сама приводит Node.js к
завершению с ненулевым кодом. Явные `try/catch` и `process.exit(1)` технически
избыточны. Реальное возможное улучшение этого пункта — определить поведение
каталога `dist/` перед сборкой, например его предварительную очистку.
### Проверка фильтра `fflate`
Проверка исходника `fflate` подтвердила, что `filter` вызывается после чтения
метаданных ZIP entry, но до копирования или декомпрессии данных. Если фильтр
возвращает `false`, `inflateSync` для этой записи не вызывается и буфер для
распакованных данных не создаётся. Поэтому добавление проверки расширения в
`filter` является корректным способом не распаковывать тяжёлые неразрешённые
entries.
### Приоритет до прода
Согласован обязательный приоритет:
1. **№2:** отбрасывать неразрешённые расширения внутри ZIP `filter` до
декомпрессии, чтобы снизить риск OOM и лишнего расхода бюджета.
2. **№1:** сбрасывать `elements.fileInputEl.value`, чтобы повторный выбор того
же файла после удаления снова генерировал `change`.
3. **№5:** объединять корневые папки с одинаковыми путями, чтобы избежать
дублирующихся деревьев при последовательном выборе.
Пункты `File.name`, `onError`, fallback-конфигурация, legacy-код и очистка
служебных каталогов отнесены ко второй очереди.
## Итог
Уточнение подтвердило, что главным техническим finding является ZIP-фильтрация
до декомпрессии. Пункты про `set_status.js` и явный `process.exit(1)` не являются
активными production-дефектами. Исправления кода в рамках этой записи не
выполнялись.
+35
View File
@@ -0,0 +1,35 @@
# План исправлений по ревью Gemini — 2026-09-05
## Обязательный релизный набор
1. Сбрасывать `fileInputEl.value` после обработки выбора, чтобы повторный выбор
того же файла снова генерировал `change`.
2. Фильтровать ZIP entries по разрешённому расширению до декомпрессии, сохраняя
отдельный проход для вложенных `.zip`.
3. Объединять корневые папки с одинаковым путём вместо создания дублей.
4. Сохранять базовое имя в `File.name`, а логический путь — в `node.path` и
результате `flattenFiles()`.
5. Показывать ошибку ZIP в статусе по умолчанию, если `onError` не передан.
6. Использовать fallback-конфигурацию при отсутствии или повреждении
`config.json` в demo-сервере.
## Статус выполнения
- Выполнено: обязательный релизный набор пунктов 1–6.
- Выполнено: удалены legacy `set_status.js`, `init_upload_table.js` и пустой
`upload/backend/` с остатками `__pycache__`.
- Выполнено: живая документация очищена от ссылок на удалённую legacy-архитектуру.
- Выполнено: `build.mjs` очищает `dist/` перед сборкой; версия повышена до `0.1.13`.
## Отложенные задачи
- Перевести тяжёлую синхронную ZIP-распаковку в Web Worker после подтверждения
реальными измерениями необходимости.
## Проверка
- Пересобрать ESM/IIFE bundles.
- Проверить синтаксис изменённых JavaScript и Python-файлов.
- Обновить версию с `0.1.11` до `0.1.12` в `package.json` и `site/app.py`.
- Выполнить browser smoke-тесты для повторного выбора, ZIP-фильтрации,
слияния папок и fallback ошибки.
@@ -0,0 +1,87 @@
# 2026-09-06: Архитектурные решения Слоя 2 и границы независимых API слоёв
## 1. Контекст и цели
Зафиксированы ключевые проектные и архитектурные решения по разработке Слоя 2 в репозитории `upload-platform` перед началом реализации:
1. Полная автономность и независимость каждого слоя как отдельного API.
2. Легкость интеграции обоих слоёв (Слой 1 + Слой 2) в целевые сервисы (`drhider`, `contractor`) без переделки исходного кода ядра.
3. Составлено подробное ТЗ и резюме в файле `LAYER2-RESUME.md`.
---
## 2. Архитектурные границы слоёв и API-контракты
### Слой 1: File Picker (v0.1.13 — готов и протестирован)
- **Зона ответственности**: выбор отдельных файлов, выбор папок (`webkitdirectory`), клиентская рекурсивная распаковка ZIP-архивов с помощью `fflate`, дедупликация, фильтрация по расширениям, проверка лимитов, отрисовка древовидного UI.
- **API-контракт**: метод `picker.getFiles()` возвращает плоский массив стандартных объектов `File`. Слой полностью изолирован от транспорта и бэкенда.
### Слой 2: Транзитная доставка через ВМ-буфер в RAM бэкенда (текущая задача)
- **Причина существования**: Ingress-контроллер k8s-кластера (`pythonk8s`) обрывает входящие запросы с телом более 64 КБ. Прямой `POST` больших файлов в кластер невозможен; исходящий трафик (egress) из кластера не ограничен.
- **Принципиальные решения**:
1. **Пофайловый транзит (Per-File Transit)** вместо накопления всей пачки в буфере:
- Файл $k$ отправляется браузером через `PUT` в буфер на ВМ.
- Браузер сразу отправляет `POST /api/upload_refs` бэкенду во Flask для одного файла $k$.
- Бэкенд забирает файл исходящим потоковым `GET` прямо в RAM сессии (`upload/backend/session/state.py`).
- Бэкенд немедленно отправляет `DELETE` на ВМ.
- Файл на ВМ удалён, буфер чист. Браузер переходит к файлу $k+1$.
- В любой момент времени в буфере на ВМ находится максимум один файл.
2. **RAM-only (хранение строго в памяти)**:
- Никакой записи на диск ни на бэкенде, ни на ВМ в постоянном режиме.
3. **Zero Hardcode**:
- Полная параметризация через конфигурацию (`vmUploadUrl` на фронтенде, `vmUploadPrefix` на бэкенде для обязательной SSRF-валидации).
4. **Автономный mock-буфер**:
- In-memory mock WebDAV (`PUT`, `GET`, `DELETE`) внутри тестового стенда `site/app.py` для автономного прогона pytest и Playwright без внешней сети.
- **API-контракт фронтенда**: функция `uploadFilesViaVm(files, options)` с колбэками прогресса по файлу/пачке, завершения файла и поддержкой отмены (`AbortController`).
- **API-контракт бэкенда**: Blueprint `upload_refs` и менеджер сессий `session` (создание сессии, потоковое вытягивание, хранение в RAM, TTL, мягкая отмена).
### Слой 3: Бизнес-логика потребителей (не входит в текущую задачу)
- Сервисы `drhider` (обфускация, LLM) и `contractor` (парсинг, diff договоров) забирают файлы из оперативной памяти сессии через `session.get_files(session_id)`.
- В `upload-platform` Слой 3 эмулируется тестовым callback/эндпоинтом, фиксирующим успешный приём файла из сессии для дальнейшей обработки.
---
## 3. Файловая структура Слоя 2 в `upload-platform`
```
upload-platform/
├── upload/
│ ├── frontend/
│ │ ├── index.js # Слой 1 (initFilePicker)
│ │ ├── table/ # UI Слоя 1
│ │ ├── zip/ # fflate
│ │ └── upload/ # Слой 2 (Фронтенд):
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # Слой 2 (Бэкенд):
│ ├── __init__.py
│ ├── upload_refs/
│ │ ├── __init__.py
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Санитизация имен файлов
│ │ └── config.py # Конфигурация по умолчанию
│ └── session/
│ ├── __init__.py
│ ├── state.py # In-memory хранилище сессий
│ ├── create_session.py
│ ├── add_file.py
│ ├── get_files.py
│ ├── store_result.py
│ ├── store_csv.py
│ ├── ttl.py
│ ├── cancel.py
│ └── cleanup.py
├── site/
│ ├── app.py # Flask demo + local RAM mock buffer
│ └── templates/index.html
├── tests/ # pytest + browser smoke
├── LAYER2-RESUME.md # Техническое задание и сводка
├── build.mjs
├── package.json
└── requirements.txt
```
---
## 4. Зафиксированные факты инфраструктуры
1. **ВМ `5.172.178.213`**: Nginx WebDAV настроен на сброс на диск (`/var/www/drhider-upload/`, `/var/www/contracts-upload/`) с cron-очисткой каждые 5 мин (`find ... -mmin +30 -delete`). Для целевого прода требуется перевод в RAM/in-memory буфер.
2. **Кластер k8s**: неймспейсы `20a75175-a58c-49cb-b8fa-e86367b1a8dc` (`drhider`), `b4523aba-b5e6-40f1-be56-bb4d2509357c` (`contractor`), `0e108526-5bb2-4757-9499-3f0bac4f0e83` (`uploader-dev`).
+145
View File
@@ -0,0 +1,145 @@
# РЕЗЮМЕ И ТЕХНИЧЕСКОЕ ЗАДАНИЕ: СЛОЙ 2 (ЗАГРУЗКА ЧЕРЕЗ ВМ-БУФЕР)
Документ составлен 2026-09-06 для старта нового рабочего контекста в репозитории [upload-platform](upload-platform).
---
## 1. ЦЕЛЬ И АРХИТЕКТУРНЫЕ ГРАНИЦЫ
В репозитории [upload-platform](upload-platform) разрабатывается и изолированно тестируется переиспользуемый встраиваемый модуль, состоящий из двух независимых слоёв:
1. **Слой 1 (Выбор файлов / File Picker)** — **ГОТОВ И ПРОТЕСТИРОВАН (v0.1.13)**:
- Фронтенд-модуль на Vanilla JS + `fflate`.
- Поддерживает выбор отдельных файлов, выбор папок (`webkitdirectory`), распаковку ZIP и вложенных архивов.
- Дедупликация, фильтрация по расширениям, древовидный UI, лимиты (`maxEntries`, `maxEntryBytes`, `maxTotalBytes`, `maxDepth`).
- Возвращает чистый плоский массив выбранных объектов `File` через метод `picker.getFiles()`. Не зависит от способа передачи данных.
2. **Слой 2 (Транзитная загрузка файлов через ВМ-буфер)** — **ТЕКУЩАЯ ЗАДАЧА**:
- Задача слоя: доставить файлы из браузера в память сессии бэкенд-приложения (Flask).
- **Почему нужен буфер**: шлюз ingress managed-кластера Kubernetes (`pythonk8s`) обрывает входящие HTTP-запросы с телом более 64 КБ. Прямой `POST /upload` больших файлов в кластер невозможен. При этом исходящий трафик (egress) из кластера не ограничен.
- **Решение**: Браузер загружает файлы во внешний буфер на ВМ, а бэкенд вытягивает их исходящими `GET`-запросами (pull) в оперативную память своей сессии.
- **Потребители модуля**: сервис анонимизации `drhider` и сервис сверки договоров (`contractor`). Оба сервиса встраивают Слой 1 + Слой 2 как подпапку `upload/` без изменения исходного кода модулей (только собственный конфиг) и поверх накладывают свою бизнес-логику (Слой 3: LLM, обфускация, парсинг, diff).
---
## 2. ФАКТИЧЕСКОЕ СОСТОЯНИЕ ИНФРАСТРУКТУРЫ (ПРОВЕРЕНО НА ВМ И В K8S)
Проверено 2026-09-06 прямыми командами на ВМ и в кластере:
1. **ВМ `5.172.178.213` (contracts.kube5s.ru)**:
- Доступ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`.
- В `nginx` настроены WebDAV-буферы:
- `/drhider-upload/` $\rightarrow$ `alias /var/www/drhider-upload/;`, CORS: `https://drhider.pythonk8s.dev.nubes.ru`.
- `/contracts-upload/` $\rightarrow$ `alias /var/www/contracts-upload/;`, CORS: `https://contractor.pythonk8s.dev.nubes.ru`.
- **Проблема текущей реализации на ВМ**:
- Nginx WebDAV пишет файлы на диск (корневой раздел).
- Cron каждые 5 минут удаляет файлы старше 30 минут: `find /var/www/drhider-upload -type f -mmin +30 -delete`.
- По требованию безопасности файлы не должны оседать на диске — обработка и буферизация должны быть строго в оперативной памяти (RAM).
2. **Kubernetes-кластер (`iot-naeel`)**:
- Под `drhider`: namespace `20a75175-a58c-49cb-b8fa-e86367b1a8dc`, хост `drhider.pythonk8s.dev.nubes.ru`.
- Под `contractor`: namespace `b4523aba-b5e6-40f1-be56-bb4d2509357c`, хост `contractor.pythonk8s.dev.nubes.ru`.
- Под `uploader-dev` (полигон): namespace `0e108526-5bb2-4757-9499-3f0bac4f0e83`, хост `uploader-dev.pythonk8s.dev.nubes.ru` (на нём крутится `upload-platform` 0.1.13).
---
## 3. ПРИНЦИПИАЛЬНЫЕ АРХИТЕКТУРНЫЕ ИЗМЕНЕНИЯ СЛОЯ 2
### Изменение 1: Пофайловый транзит (Streaming / Per-File Transit) вместо батча
- **Как было раньше**: Браузер в цикле заливал все файлы пачки в буфер на ВМ, и только в самом конце отправлял во Flask один общий запрос `/api/upload_refs` со списком всех ссылок. Буфер был вынужден одновременно хранить все файлы пачки (до сотен мегабайт).
- **Как должно быть**:
1. Браузер берет **один файл** $\rightarrow$ делает `PUT` в буфер на ВМ.
2. Браузер сразу делает `POST /api/upload_refs` для **этого одного файла**.
3. Flask вытягивает его исходящим потоковым `GET` прямо в память сессии (`site/session.py`) и сразу отправляет `DELETE` на ВМ.
4. Файл на ВМ удален. Браузер переходит к следующему файлу.
- **Результат**: В буфере на ВМ в любой момент времени находится максимум один файл. Общий объем памяти буфера минимален.
### Изменение 2: Хранение строго в памяти (RAM)
- Никаких временных файлов на диске.
- На бэкенде Flask файлы хранятся в RAM-структуре сессии (`upload/backend/session/state.py`).
- На ВМ для буфера не должен использоваться сброс на диск (на стороне Nginx требуется проксирование в память или легковесный in-memory микросервис).
### Изменение 3: Полная параметризация (Zero Hardcode)
- Прямой IP из браузера поверх HTTPS невозможен (Mixed Content, отсутствие доверенного TLS-сертификата).
- В коде не должно быть зашитых хостов (`contracts.kube5s.ru` и т.д.).
- Все адреса передаются через конфигурацию:
- Фронтенд: `vmUploadUrl` (например, `https://contracts.kube5s.ru/drhider-upload/` или локальный URL).
- Бэкенд: `vmUploadPrefix` (для обязательной SSRF-валидации входящих URL перед pull).
### Изменение 4: Автономный mock-буфер для тестов в `upload-platform`
- Тесты в [upload-platform](upload-platform) не должны зависеть от внешней сети или чужих боевых ВМ.
- В тестовый стенд и dev-сервер [site/app.py](upload-platform/site/app.py) встраивается локальный легковесный in-memory mock WebDAV (обработка `PUT`, `GET`, `DELETE`), что позволяет гонять Playwright- и Python-тесты полностью оффлайн.
---
## 4. СТРУКТУРА МОДУЛЯ В `upload-platform`
```
upload-platform/
├── upload/
│ ├── frontend/
│ │ ├── index.js # Точка входа Слоя 1 (initFilePicker)
│ │ ├── table/ # Логика и UI Слоя 1
│ │ ├── zip/ # Распаковка архивов (fflate)
│ │ └── upload/ # СЛОЙ 2 (ФРОНТЕНД):
│ │ ├── put_to_vm.js # XHR PUT одного файла на буфер с прогрессом и abort
│ │ └── upload_via_vm.js # Пофайловый транзит (PUT -> upload_refs -> repeat)
│ └── backend/ # СЛОЙ 2 (БЭКЕНД):
│ ├── __init__.py
│ ├── upload_refs/
│ │ ├── __init__.py
│ │ ├── blueprint.py # Flask Blueprint (POST /api/upload_refs)
│ │ ├── pull_file.py # Исходящий потоковый GET с ретраями (httpx)
│ │ ├── safe_name.py # Санитизация имен (защита от path traversal)
│ │ └── config.py # Дефолтные параметры (retries, delay)
│ └── session/
│ ├── __init__.py
│ ├── state.py # In-memory хранилище сессий (_sessions, lock, TTL)
│ ├── create_session.py # Создание сессии (UUID)
│ ├── add_file.py # Добавление файла с проверкой лимита памяти
│ ├── get_files.py # Получение списка файлов
│ ├── store_result.py # Сохранение ZIP-результата
│ ├── store_csv.py # Сохранение CSV-результата
│ ├── ttl.py # touch, pause_ttl, resume_ttl
│ ├── cancel.py # Мягкая отмена сессии (Event)
│ └── cleanup.py # Очистка сессии
├── site/
│ ├── app.py # Flask demo (Слой 1 + Слой 2 + local mock buffer)
│ └── templates/index.html # Demo UI со сквозным сценарием выбора и загрузки
├── tests/ # Тесты Слоя 2 (pytest + browser smoke)
├── build.mjs # Сборка бандлов (ESM / IIFE)
├── package.json # Версионирование платформы
└── requirements.txt # Зависимости Python (Flask, httpx, pytest)
```
---
## 5. ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ В НОВОМ ЧАТЕ
1. **Шаг 1. Перенос и адаптация бэкенда Слоя 2**:
- Перенести `backend/session/` и `backend/upload_refs/` из [upload/backend](upload/backend) в [upload-platform/upload/backend](upload-platform/upload/backend).
- В `upload-platform/requirements.txt` зафиксировать `httpx>=0.27.0`.
- Проверить чистоту импортов и типизацию.
2. **Шаг 2. Перенос и адаптация фронтенда Слоя 2 на пофайловый транзит**:
- Создать [upload-platform/upload/frontend/upload/put_to_vm.js](upload-platform/upload/frontend/upload/put_to_vm.js) (XHR PUT с прогрессом и возможностью `abort`).
- Создать [upload-platform/upload/frontend/upload/upload_via_vm.js](upload-platform/upload/frontend/upload/upload_via_vm.js) с реализацией **пофайловой передачи**:
- цикл по файлам;
- загрузка файла $k$ на буфер;
- немедленный вызов `/api/upload_refs` для файла $k$;
- обновление прогресса в UI;
- переход к файлу $k+1$.
3. **Шаг 3. Локальный mock-буфер и интеграция в `site/app.py`**:
- Встроить в [upload-platform/site/app.py](upload-platform/site/app.py) тестовый Blueprint mock-буфера (`PUT`, `GET`, `DELETE` в оперативной памяти).
- Подключить `create_upload_refs_blueprint` к приложению.
- Обновить [upload-platform/site/templates/index.html](upload-platform/site/templates/index.html), добавив кнопку «Загрузить» и отображение прогресса пофайловой передачи.
4. **Шаг 4. Написание автоматических тестов**:
- Python unit-тесты (`pytest`): `safe_name`, `session` (TTL, лимиты, add/get), `upload_refs` (SSRF-блокировка, pull с mock-буфера, удаление после pull).
- Playwright browser-тесты: сквозной сценарий от выбора файлов в `initFilePicker` до их успешного появления в сессии Flask.
5. **Шаг 5. Сборка, bump версии и документация**:
- Обновить `build.mjs` и `package.json` (bump версии).
- Написать подробный [upload-platform/README.md](upload-platform/README.md) с инструкцией для агентов, как встроить оба слоя в сторонний сервис за 2 минуты.
- Фиксация коммитом и пуш.
-11
View File
@@ -77,22 +77,11 @@ upload-platform/
│ │ │ ├── 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
+76 -34
View File
@@ -1,40 +1,68 @@
# Upload Platform
Переиспользуемый browser-only file-picker. Модуль выбирает отдельные файлы,
папки и ZIP, строит сворачиваемое дерево и возвращает исходные browser `File`
objects с полными путями. Файлы не отправляются на VM и не загружаются через
backend.
Переиспользуемый browser-only file-picker версии `0.1.13`. Он выбирает файлы,
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
загружает его на VM, в Flask или в другое хранилище.
## Слои
## Карта файлов
| Слой | Ответственность |
### Корень проекта
| Путь | Назначение | Статус |
|---|---|---|
| `package.json` | Версия пакета и команда `npm run build`. | Используется |
| `package-lock.json` | Зафиксированные npm-зависимости. | Используется |
| `build.mjs` | esbuild-сборка ESM и IIFE bundle. | Используется |
| `requirements.txt` | Python-зависимости demo-сервера. | Используется demo |
| `config.json` | Конфигурация demo: разрешённые расширения. | Используется demo |
| `dist/file-picker.esm.js` | Готовый ESM bundle для интеграции. | Используется |
| `dist/file-picker.iife.js` | Готовый IIFE bundle `FilePicker`. | Используется |
| `upload/` | Исходники переиспользуемого picker-модуля. | Используется |
| `site/` | Flask demo, который раздаёт страницу и bundle. | Используется только demo |
| `docs/` | Справочная и историческая документация. | См. раздел LEGACY |
| `HISTORY/` | Архив решений, ревью и результатов проверок. | Только история |
### `upload/` — исходники picker-а
| Путь | Назначение |
|---|---|
| Конфиг | `allowedExt`, `labels`, `layout`, `limits` |
| Picker | таблица, дедупликация, ZIP, рекурсивный обход папки |
| Интегрирующее приложение | получает leaf-файлы через `getFiles()` |
| `upload/README.md` | Краткая инструкция интеграции готового bundle. |
| `upload/config.example.json` | Пример конфигурации разрешённых расширений. |
| `upload/frontend/index.js` | Единственная актуальная точка входа `initFilePicker(config)`, DOM, lifecycle API. |
| `upload/frontend/table/add_file_with_dedup.js` | Дедупликация файлов и слияние одинаковых корней. |
| `upload/frontend/table/esc.js` | HTML-экранирование имён, путей и атрибутов. |
| `upload/frontend/table/fs.js` | Форматирование размеров файлов. |
| `upload/frontend/table/on_files_change.js` | Выбор файлов, фильтрация, ZIP-разбор и fallback ошибок. |
| `upload/frontend/table/on_folder_change.js` | Выбор папки через `webkitdirectory` и построение дерева. |
| `upload/frontend/table/rebase_tree.js` | Добавление префикса пути без изменения базового `File.name`. |
| `upload/frontend/table/render.js` | Рендер дерева, счётчик, поиск узлов и `flattenFiles()`. |
| `upload/frontend/zip/list_zip_files.js` | Безопасный рекурсивный разбор ZIP через встроенный `fflate`. |
## Структура
### `site/` — demo-обёртка
```
upload-platform/
├── requirements.txt
├── config.json # слой 0 (рабочий конфиг этого демо)
├── package.json # сборка browser bundle
├── build.mjs # esbuild: ESM + IIFE
├── dist/ # готовые bundle для встраивания
├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
│ ├── README.md # инструкция интеграции
│ ├── config.example.json
│ ├── frontend/ # picker (vanilla JS, ES-модули)
│ │ ├── zip/
│ │ ├── table/
│ └── backend/ # LEGACY: VM upload/session remnants
└── site/ # демо-обёртка (не переиспользуется)
├── app.py
├── routes/
├── templates/
└── static/
```
| Путь | Назначение |
|---|---|
| `site/app.py` | Flask entrypoint, `/`, `/health`, раздача bundle и совместимый маршрут исходников. |
| `site/templates/index.html` | Demo-страница и вызов `FilePicker.initFilePicker()`. |
| `site/static/style.css` | Стили demo-страницы. |
| `site/routes/` | Текущий каталог маршрутов; прикладного upload backend в нём нет. |
### `docs/`
| Путь | Назначение | Статус |
|---|---|---|
| `docs/CODE-REFERENCE.md` | Справочник актуального picker-кода и API. | Использовать |
| `docs/PLAN-componentization.md` | Исторический план перехода к bundle API. | LEGACY, не использовать как план |
| `docs/sonnet-architecture-review-prompt.md` | Исторический prompt ревью старой архитектуры. | LEGACY, не использовать |
| `docs/*-architecture-review-response.md` | Ответы на исторические ревью. | История, не спецификация |
### `HISTORY/`
Файлы `HISTORY/` фиксируют состояние проекта на даты ревью, тестов и решений.
Они могут описывать удалённые файлы, VM-upload или старые версии. Это архив,
а не инструкция: при расхождении с кодом руководствоваться только исходниками,
`README.md`, `upload/README.md` и `docs/CODE-REFERENCE.md`.
## Локальный запуск
@@ -76,10 +104,24 @@ ZIP-файлы используются как контейнеры и раск
## Legacy
`upload/backend/` и старые VM-upload упоминания сохранены только для истории и
совместимости с предыдущими этапами проекта. Текущая picker-only интеграция их
не импортирует и не требует Flask API для обработки файлов.
Переиспользуемая инструкция находится в `upload/README.md`.
### Удалено и не должно восстанавливаться
- `upload/backend/` — старый backend VM-upload, sessions и pull API;
- `upload/frontend/table/init_upload_table.js` — старый entry point с готовыми DOM-узлами;
- `upload/frontend/table/set_status.js` — неиспользуемый legacy helper;
- `upload/frontend/upload/` — старый frontend VM-upload layer.
Эти части не входят в сборку `build.mjs`, не импортируются текущим picker-ом и
не являются частью API. Не добавлять их обратно при интеграции.
### Исторические документы
`PLAN.md`, `docs/PLAN-componentization.md`, review prompts и записи `HISTORY/`
могут содержать описания старой VM/backend-архитектуры. Они нужны для аудита
решений, но не являются актуальной спецификацией и не должны использоваться
как план разработки.
Актуальная инструкция интеграции находится в `upload/README.md`.
Полный справочник функций, состояния, DOM-контрактов и ограничений находится в
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
+3
View File
@@ -1,4 +1,7 @@
import { build } from 'esbuild';
import { rm } from 'node:fs/promises';
await rm('dist', { recursive: true, force: true });
const shared = {
entryPoints: ['upload/frontend/index.js'],
+35 -4
View File
@@ -475,7 +475,7 @@ function unzipSync(data, opts) {
// upload/frontend/table/rebase_tree.js
function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
@@ -492,7 +492,7 @@ function extensionAllowed(name, allowedExt) {
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
function makeFile(data, name) {
return new File([data], name);
return new File([data], name.split("/").at(-1));
}
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
@@ -521,6 +521,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
filter: (entry) => {
budget.entries += 1;
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
const lowerName = entry.name.toLowerCase();
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false;
if (entry.originalSize > limits.maxEntryBytes) return false;
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
@@ -570,9 +572,28 @@ function addFileWithDedup(state, fileNode) {
};
const accepted = accept(fileNode);
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
if (accepted.kind !== "file") {
const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === "file") {
const duplicate = target.children.some((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== "file" && candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
// upload/frontend/table/esc.js
function esc(value) {
@@ -682,7 +703,11 @@ async function addFiles(state, cfg, files, elements) {
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
if (typeof cfg.onError === "function") cfg.onError(error, file);
if (typeof cfg.onError === "function") {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
}
continue;
}
}
@@ -695,6 +720,7 @@ function onFilesChange(state, cfg, elements) {
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
elements.fileInputEl.value = "";
state.busy = false;
}
};
@@ -751,7 +777,11 @@ function onFolderChange(state, cfg, elements) {
addToFolder(zip);
}
} catch (error) {
if (typeof cfg.onError === "function") cfg.onError(error, file);
if (typeof cfg.onError === "function") {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
@@ -884,6 +914,7 @@ function initFilePicker(config) {
const elements = {
fileInputEl: root.querySelector(".fp-file-input"),
folderInputEl: root.querySelector(".fp-folder-input"),
statusEl: root.querySelector(".fp-status"),
tableBodyEl: root.querySelector(".fp-table-body"),
countEl: root.querySelector(".fp-count")
};
+35 -4
View File
@@ -501,7 +501,7 @@ var FilePicker = (() => {
// upload/frontend/table/rebase_tree.js
function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
@@ -518,7 +518,7 @@ var FilePicker = (() => {
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
function makeFile(data, name) {
return new File([data], name);
return new File([data], name.split("/").at(-1));
}
function safeEntryParts(entryName) {
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
@@ -547,6 +547,8 @@ var FilePicker = (() => {
filter: (entry) => {
budget.entries += 1;
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
const lowerName = entry.name.toLowerCase();
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith(".zip")) return false;
if (entry.originalSize > limits.maxEntryBytes) return false;
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
@@ -596,9 +598,28 @@ var FilePicker = (() => {
};
const accepted = accept(fileNode);
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
if (accepted.kind !== "file") {
const existing = state.nodes.find((node2) => node2.kind !== "file" && node2.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === "file") {
const duplicate = target.children.some((existing2) => existing2.kind === "file" && existing2.path === child.path && existing2.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== "file" && candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
// upload/frontend/table/esc.js
function esc(value) {
@@ -708,7 +729,11 @@ var FilePicker = (() => {
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
if (typeof cfg.onError === "function") cfg.onError(error, file);
if (typeof cfg.onError === "function") {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
}
continue;
}
}
@@ -721,6 +746,7 @@ var FilePicker = (() => {
try {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
elements.fileInputEl.value = "";
state.busy = false;
}
};
@@ -777,7 +803,11 @@ var FilePicker = (() => {
addToFolder(zip);
}
} catch (error) {
if (typeof cfg.onError === "function") cfg.onError(error, file);
if (typeof cfg.onError === "function") {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `\u041E\u0448\u0438\u0431\u043A\u0430 \u0447\u0442\u0435\u043D\u0438\u044F \u0430\u0440\u0445\u0438\u0432\u0430 ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
@@ -910,6 +940,7 @@ var FilePicker = (() => {
const elements = {
fileInputEl: root.querySelector(".fp-file-input"),
folderInputEl: root.querySelector(".fp-folder-input"),
statusEl: root.querySelector(".fp-status"),
tableBodyEl: root.querySelector(".fp-table-body"),
countEl: root.querySelector(".fp-count")
};
+6 -30
View File
@@ -1,7 +1,7 @@
# Справочник кода Upload Platform
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
и API загрузки являются legacy и в текущем коде не используются.
и API загрузки относятся к отменённой архитектуре и в текущем коде отсутствуют.
## Архитектура
@@ -71,7 +71,7 @@ Flask отдаёт страницу и готовый bundle. Все выбра
| Функция | Вход | Выход | Побочные эффекты |
|---|---|---|---|
| `rebaseTree(root, prefix)` | Дерево и префикс папки | То же дерево | Рекурсивно меняет `path`; для leaf создаёт File с новым именем. |
| `rebaseTree(root, prefix)` | Дерево и префикс папки | То же дерево | Рекурсивно меняет `path`; для leaf сохраняет базовое имя `File.name`. |
Модуль является единственной реализацией rebasing для ZIP внутри выбранной папки
и для nested ZIP.
@@ -107,26 +107,6 @@ callback задан. `finally` обязательно освобождает `bu
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
дочерние строки только при `expanded === true`.
### `init_upload_table.js` (legacy)
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `removeFileMeta(state, node)` | state и удаляемое поддерево | `void` | Рекурсивно удаляет dedup-ключи. Внутренняя функция. |
| `removeNode(state, id)` | state и id | `void` | Удаляет узел из родительского массива. Внутренняя функция. |
| `initUploadTable(cfg)` | `allowedExt` и четыре DOM-элемента | API-объект | Создаёт state, события, render и публичные операции. |
Публичный API legacy-совместимости:
| Метод | Вход | Результат |
|---|---|---|
| `pickFiles()` | нет | Открывает обычный file input. |
| `pickFolder()` | нет | Открывает folder input. |
| `addFiles(files)` | FileList/Array | Добавляет файлы с фильтрацией и ZIP-разбором. |
| `getFiles()` | нет | `{path, name, size, file}[]` только для leaf. |
| `remove(id)` | id узла | Удаляет узел и его dedup-ключи. |
| `render()` | нет | Перерисовывает текущий state. |
| `clear()` | нет | Очищает дерево и dedup Set. |
## `upload/frontend/zip`
### `list_zip_files.js`
@@ -134,7 +114,7 @@ callback задан. `finally` обязательно освобождает `bu
| Функция | Вход | Выход | Назначение |
|---|---|---|---|
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File. Внутренняя. |
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File с базовым именем. Внутренняя. |
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
@@ -168,7 +148,7 @@ vendor script не требуется.
| `VERSION` | константа | Версия страницы |
| `index()` / `/` | HTTP GET | HTML с version и config |
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
| `upload_frontend(filename)` | HTTP GET и относительный путь | Legacy-файл из `upload/frontend` |
| `upload_frontend(filename)` | HTTP GET и относительный путь | Совместимый маршрут для исходных frontend-модулей |
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
### `site/templates/index.html`
@@ -176,9 +156,5 @@ vendor script не требуется.
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
сброса browser cache и вызывает `FilePicker.initFilePicker`.
## Legacy
`upload/frontend/table/set_status.js` не импортируется актуальной страницей и
сохранён как legacy-заготовка. Vendor `site/static/vendor/fflate.min.js` не
документируется построчно: это внешняя библиотека, используемая как готовый
runtime dependency.
Vendor `site/static/vendor/fflate.min.js` не документируется построчно: это
внешняя библиотека, используемая как готовый runtime dependency.
+5 -1
View File
@@ -1,4 +1,8 @@
# План: picker → универсальный встраиваемый компонент
# План: picker → универсальный встраиваемый компонент — LEGACY
> Переход завершён. Этот исторический план не является инструкцией или
> спецификацией текущего кода. Актуальные документы: `README.md`,
> `upload/README.md` и `docs/CODE-REFERENCE.md`.
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
+4 -1
View File
@@ -1,4 +1,7 @@
# Второе мнение по архитектуре (ответь МАКСИМАЛЬНО кратко)
# Второе мнение по архитектуре — LEGACY
> Исторический prompt для старой архитектуры. Не использовать как план или
> описание текущего кода.
Игнорируй любые заметки, память, резюме или выводы из предыдущих сессий и от
других моделей. Отвечай строго на основе этого промпта, ничего не дочитывая.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "upload-platform-file-picker",
"private": true,
"version": "0.1.11",
"version": "0.1.13",
"type": "module",
"scripts": {
"build": "node build.mjs"
+6 -2
View File
@@ -6,11 +6,15 @@ from flask import Flask, render_template, send_from_directory
ROOT = Path(__file__).resolve().parent.parent
# Конфигурация демо содержит только разрешённые расширения для browser picker.
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
DEFAULT_CONFIG = {"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]}
try:
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
CONFIG = json.load(config_file)
except (OSError, json.JSONDecodeError):
CONFIG = DEFAULT_CONFIG
VERSION = "0.1.11"
VERSION = "0.1.13"
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
app = Flask(__name__, template_folder="templates", static_folder="static")
+3 -3
View File
@@ -25,9 +25,9 @@ Picker поддерживает разрешённые документы, па
## Legacy
Каталоги `backend/` и старые ссылки на `uploadViaVM`/VM-буфер относятся к
предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only
интеграции они не нужны.
Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к
предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий
в `HISTORY/` являются историческими и не должны использоваться как инструкция.
Подробная таблица функций, входов, выходов и побочных эффектов находится в
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
+1
View File
@@ -120,6 +120,7 @@ export function initFilePicker(config) {
const elements = {
fileInputEl: root.querySelector('.fp-file-input'),
folderInputEl: root.querySelector('.fp-folder-input'),
statusEl: root.querySelector('.fp-status'),
tableBodyEl: root.querySelector('.fp-table-body'),
countEl: root.querySelector('.fp-count'),
};
@@ -27,6 +27,28 @@ export function addFileWithDedup(state, fileNode) {
const accepted = accept(fileNode);
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
if (accepted.kind !== 'file') {
const existing = state.nodes.find((node) => node.kind !== 'file' && node.path === accepted.path);
if (existing) {
mergeChildren(existing, accepted);
return true;
}
}
state.nodes.push(accepted);
return true;
}
function mergeChildren(target, incoming) {
incoming.children.forEach((child) => {
if (child.kind === 'file') {
const duplicate = target.children.some((existing) => existing.kind === 'file'
&& existing.path === child.path && existing.file.size === child.file.size);
if (!duplicate) target.children.push(child);
return;
}
const existing = target.children.find((candidate) => candidate.kind !== 'file'
&& candidate.path === child.path);
if (existing) mergeChildren(existing, child);
else target.children.push(child);
});
}
@@ -1,98 +0,0 @@
import { addFiles, onFilesChange } from './on_files_change.js';
import { onFolderChange } from './on_folder_change.js';
import { findNode, flattenFiles, render } from './render.js';
/**
* Удаляет ключи дедупликации для leaf-файлов внутри удаляемого поддерева.
*
* @param {{fileKeys: Set<string>}} state Состояние дедупликации.
* @param {{kind: string, path: string, file?: File, children?: Array}} node Удаляемый узел.
* @returns {void}
*/
function removeFileMeta(state, node) {
if (node.kind === 'file') {
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
return;
}
node.children.forEach((child) => removeFileMeta(state, child));
}
/**
* Удаляет узел из дерева и синхронно освобождает его dedup-ключи.
*
* @param {{nodes: Array, fileKeys: Set<string>}} state Состояние таблицы.
* @param {string} id Идентификатор узла.
* @returns {void} Ничего не делает, если id не найден.
*/
function removeNode(state, id) {
const found = findNode(state.nodes, id);
if (!found) return;
removeFileMeta(state, found.node);
found.nodes.splice(found.nodes.indexOf(found.node), 1);
}
/**
* Создаёт picker и возвращает его публичный API.
*
* @param {{allowedExt: string[], fileInputEl: HTMLInputElement,
* folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} cfg
* Конфигурация и обязательные DOM-элементы интегратора.
* @returns {{pickFiles: Function, pickFolder: Function, addFiles: Function,
* getFiles: Function, remove: Function, render: Function, clear: Function}}
* Управляющий API без прямого доступа к внутреннему state.
*
* Функция регистрирует DOM-события один раз, хранит дерево и dedup Set внутри
* замыкания и сразу рисует пустое состояние. Внешнее приложение получает только
* операции выбора, добавления, чтения и удаления.
*/
export function initUploadTable(cfg) {
// Нормализованный набор DOM-ссылок передаётся во все функции рендера.
const elements = {
fileInputEl: cfg.fileInputEl,
folderInputEl: cfg.folderInputEl,
tableBodyEl: cfg.tableBodyEl,
countEl: cfg.countEl,
};
// nodes — дерево; fileKeys — ключи path+NUL+size; busy защищает async change handlers.
const state = { nodes: [], fileKeys: new Set(), busy: false };
elements.fileInputEl.addEventListener('change', onFilesChange(state, cfg, elements));
elements.folderInputEl.addEventListener('change', onFolderChange(state, cfg, elements));
elements.tableBodyEl.addEventListener('click', (event) => {
// Делегирование событий позволяет обслуживать динамически созданные кнопки.
const toggle = event.target.closest('[data-toggle]');
if (toggle) {
const found = findNode(state.nodes, toggle.dataset.toggle);
if (found) found.node.expanded = !found.node.expanded;
render(state, elements, cfg);
return;
}
const remove = event.target.closest('[data-remove]');
if (remove) {
removeNode(state, remove.dataset.remove);
render(state, elements, cfg);
}
});
const api = {
// Открывают системные диалоги, не обходя браузерные ограничения File API.
pickFiles: () => elements.fileInputEl.click(),
pickFolder: () => elements.folderInputEl.click(),
// Программное добавление использует тот же фильтр и ZIP-парсер, что и input.
addFiles: (files) => addFiles(state, cfg, files, elements),
// Возвращает только leaf-файлы; группы и внутренний state наружу не выдаются.
getFiles: () => flattenFiles(state.nodes),
remove: (id) => {
removeNode(state, id);
render(state, elements, cfg);
},
render: () => render(state, elements),
clear: () => {
// Очистка сбрасывает и дерево, и dedup Set, чтобы повторный выбор был возможен.
state.nodes = [];
state.fileKeys.clear();
render(state, elements);
},
};
api.render();
return api;
}
+6 -1
View File
@@ -34,7 +34,11 @@ export async function addFiles(state, cfg, files, elements) {
if (zipTree) addFileWithDedup(state, zipTree);
} catch (error) {
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
if (typeof cfg.onError === 'function') cfg.onError(error, file);
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
}
@@ -61,6 +65,7 @@ export function onFilesChange(state, cfg, elements) {
await addFiles(state, cfg, elements.fileInputEl.files, elements);
} finally {
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
elements.fileInputEl.value = '';
state.busy = false;
}
};
+5 -1
View File
@@ -60,7 +60,11 @@ export function onFolderChange(state, cfg, elements) {
}
} catch (error) {
// Ошибка одного ZIP не должна терять остальные файлы каталога.
if (typeof cfg.onError === 'function') cfg.onError(error, file);
if (typeof cfg.onError === 'function') {
cfg.onError(error, file);
} else if (elements.statusEl) {
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
}
continue;
}
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
+3 -3
View File
@@ -5,12 +5,12 @@
* @param {string} prefix Путь выбранной папки, в которую попал узел.
* @returns {object} Тот же узел после изменения путей.
*
* Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные
* browser File и путь, возвращаемый через getFiles(), оставались согласованными.
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
* хранится отдельно в узле дерева.
*/
export function rebaseTree(root, prefix) {
root.path = `${prefix}/${root.path}`;
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
if (root.file) root.file = new File([root.file], root.file.name, { lastModified: root.file.lastModified });
root.children.forEach((child) => rebaseTree(child, prefix));
return root;
}
-21
View File
@@ -1,21 +0,0 @@
import { flattenFiles } from './render.js';
/**
* Legacy helper: заменяет HTML статуса у найденного leaf-файла.
*
* @param {string} path Полный логический путь файла в текущем дереве.
* @param {string} html Готовая HTML-строка статуса; вызывающая сторона отвечает
* за её безопасность.
* @param {{nodes: Array}} state Состояние дерева, из которого ищется файл.
* @param {{tableBodyEl: HTMLElement}} elements DOM-элементы старого renderer.
* @returns {void} Ничего не возвращает; при отсутствии файла или ячейки ничего
* не изменяет.
*/
export function setStatus(path, html, state, elements) {
const file = flattenFiles(state.nodes).find((item) => item.path === path);
if (!file) return;
const cell = Array.from(elements.tableBodyEl.querySelectorAll('.tree-row.tree-file'))
.find((row) => row.dataset.path === file.path)
?.querySelector('td:nth-child(3)');
if (cell) cell.innerHTML = html;
}
+4 -2
View File
@@ -15,9 +15,9 @@ function extensionAllowed(name, allowedExt) {
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
}
/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
function makeFile(data, name) {
return new File([data], name);
return new File([data], name.split('/').at(-1));
}
/**
@@ -74,6 +74,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
filter: (entry) => {
budget.entries += 1;
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
const lowerName = entry.name.toLowerCase();
if (!extensionAllowed(lowerName, allowedExt) && !lowerName.endsWith('.zip')) return false;
if (entry.originalSize > limits.maxEntryBytes) return false;
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
throw new Error('Превышен суммарный размер распакованных ZIP entries');