Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea7ba058c | ||
|
|
6586b5d893 | ||
|
|
017bd8c354 |
@@ -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__` и любых каталогов не выполнялось.
|
||||||
@@ -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-дефектами. Исправления кода в рамках этой записи не
|
||||||
|
выполнялись.
|
||||||
@@ -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`).
|
||||||
@@ -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 минуты.
|
||||||
|
- Фиксация коммитом и пуш.
|
||||||
@@ -77,22 +77,11 @@ upload-platform/
|
|||||||
│ │ │ ├── esc.js
|
│ │ │ ├── esc.js
|
||||||
│ │ │ ├── add_file_with_dedup.js
|
│ │ │ ├── add_file_with_dedup.js
|
||||||
│ │ │ ├── render.js
|
│ │ │ ├── render.js
|
||||||
│ │ │ ├── set_status.js
|
|
||||||
│ │ │ ├── on_files_change.js
|
│ │ │ ├── on_files_change.js
|
||||||
│ │ │ ├── on_folder_change.js
|
│ │ │ ├── on_folder_change.js
|
||||||
│ │ │ └── init_upload_table.js
|
|
||||||
│ │ └── upload/ # слой 2 (фронт)
|
│ │ └── upload/ # слой 2 (фронт)
|
||||||
│ │ ├── put_to_vm.js
|
│ │ ├── put_to_vm.js
|
||||||
│ │ └── upload_via_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
|
│ ├── state.py
|
||||||
│ ├── create_session.py
|
│ ├── create_session.py
|
||||||
│ ├── add_file.py
|
│ ├── add_file.py
|
||||||
|
|||||||
@@ -1,40 +1,68 @@
|
|||||||
# Upload Platform
|
# Upload Platform
|
||||||
|
|
||||||
Переиспользуемый browser-only file-picker. Модуль выбирает отдельные файлы,
|
Переиспользуемый browser-only file-picker версии `0.1.13`. Он выбирает файлы,
|
||||||
папки и ZIP, строит сворачиваемое дерево и возвращает исходные browser `File`
|
папки и ZIP, строит дерево и возвращает интегрирующему приложению browser
|
||||||
objects с полными путями. Файлы не отправляются на VM и не загружаются через
|
`File` objects. Содержимое файлов остаётся в браузере: текущий проект не
|
||||||
backend.
|
загружает его на 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` |
|
| `upload/README.md` | Краткая инструкция интеграции готового bundle. |
|
||||||
| Picker | таблица, дедупликация, ZIP, рекурсивный обход папки |
|
| `upload/config.example.json` | Пример конфигурации разрешённых расширений. |
|
||||||
| Интегрирующее приложение | получает leaf-файлы через `getFiles()` |
|
| `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
|
| `site/app.py` | Flask entrypoint, `/`, `/health`, раздача bundle и совместимый маршрут исходников. |
|
||||||
├── config.json # слой 0 (рабочий конфиг этого демо)
|
| `site/templates/index.html` | Demo-страница и вызов `FilePicker.initFilePicker()`. |
|
||||||
├── package.json # сборка browser bundle
|
| `site/static/style.css` | Стили demo-страницы. |
|
||||||
├── build.mjs # esbuild: ESM + IIFE
|
| `site/routes/` | Текущий каталог маршрутов; прикладного upload backend в нём нет. |
|
||||||
├── dist/ # готовые bundle для встраивания
|
|
||||||
├── upload/ # ← переиспользуемый модуль (копируется в любой проект)
|
### `docs/`
|
||||||
│ ├── README.md # инструкция интеграции
|
|
||||||
│ ├── config.example.json
|
| Путь | Назначение | Статус |
|
||||||
│ ├── frontend/ # picker (vanilla JS, ES-модули)
|
|---|---|---|
|
||||||
│ │ ├── zip/
|
| `docs/CODE-REFERENCE.md` | Справочник актуального picker-кода и API. | Использовать |
|
||||||
│ │ ├── table/
|
| `docs/PLAN-componentization.md` | Исторический план перехода к bundle API. | LEGACY, не использовать как план |
|
||||||
│ └── backend/ # LEGACY: VM upload/session remnants
|
| `docs/sonnet-architecture-review-prompt.md` | Исторический prompt ревью старой архитектуры. | LEGACY, не использовать |
|
||||||
└── site/ # демо-обёртка (не переиспользуется)
|
| `docs/*-architecture-review-response.md` | Ответы на исторические ревью. | История, не спецификация |
|
||||||
├── app.py
|
|
||||||
├── routes/
|
### `HISTORY/`
|
||||||
├── templates/
|
|
||||||
└── static/
|
Файлы `HISTORY/` фиксируют состояние проекта на даты ревью, тестов и решений.
|
||||||
```
|
Они могут описывать удалённые файлы, VM-upload или старые версии. Это архив,
|
||||||
|
а не инструкция: при расхождении с кодом руководствоваться только исходниками,
|
||||||
|
`README.md`, `upload/README.md` и `docs/CODE-REFERENCE.md`.
|
||||||
|
|
||||||
## Локальный запуск
|
## Локальный запуск
|
||||||
|
|
||||||
@@ -76,10 +104,24 @@ ZIP-файлы используются как контейнеры и раск
|
|||||||
|
|
||||||
## Legacy
|
## Legacy
|
||||||
|
|
||||||
`upload/backend/` и старые VM-upload упоминания сохранены только для истории и
|
### Удалено и не должно восстанавливаться
|
||||||
совместимости с предыдущими этапами проекта. Текущая picker-only интеграция их
|
|
||||||
не импортирует и не требует Flask API для обработки файлов.
|
- `upload/backend/` — старый backend VM-upload, sessions и pull API;
|
||||||
Переиспользуемая инструкция находится в `upload/README.md`.
|
- `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-контрактов и ограничений находится в
|
Полный справочник функций, состояния, DOM-контрактов и ограничений находится в
|
||||||
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
|
[`docs/CODE-REFERENCE.md`](docs/CODE-REFERENCE.md).
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { build } from 'esbuild';
|
import { build } from 'esbuild';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
|
||||||
|
await rm('dist', { recursive: true, force: true });
|
||||||
|
|
||||||
const shared = {
|
const shared = {
|
||||||
entryPoints: ['upload/frontend/index.js'],
|
entryPoints: ['upload/frontend/index.js'],
|
||||||
|
|||||||
Vendored
+35
-4
@@ -475,7 +475,7 @@ function unzipSync(data, opts) {
|
|||||||
// upload/frontend/table/rebase_tree.js
|
// upload/frontend/table/rebase_tree.js
|
||||||
function rebaseTree(root, prefix) {
|
function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
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));
|
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -492,7 +492,7 @@ function extensionAllowed(name, allowedExt) {
|
|||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
function makeFile(data, name) {
|
function makeFile(data, name) {
|
||||||
return new File([data], name);
|
return new File([data], name.split("/").at(-1));
|
||||||
}
|
}
|
||||||
function safeEntryParts(entryName) {
|
function safeEntryParts(entryName) {
|
||||||
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
||||||
@@ -521,6 +521,8 @@ async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
|||||||
filter: (entry) => {
|
filter: (entry) => {
|
||||||
budget.entries += 1;
|
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");
|
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 (entry.originalSize > limits.maxEntryBytes) return false;
|
||||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
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");
|
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);
|
const accepted = accept(fileNode);
|
||||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
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);
|
state.nodes.push(accepted);
|
||||||
return true;
|
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
|
// upload/frontend/table/esc.js
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
@@ -682,7 +703,11 @@ async function addFiles(state, cfg, files, elements) {
|
|||||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,6 +720,7 @@ function onFilesChange(state, cfg, elements) {
|
|||||||
try {
|
try {
|
||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
|
elements.fileInputEl.value = "";
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -751,7 +777,11 @@ function onFolderChange(state, cfg, elements) {
|
|||||||
addToFolder(zip);
|
addToFolder(zip);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
@@ -884,6 +914,7 @@ function initFilePicker(config) {
|
|||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: root.querySelector(".fp-file-input"),
|
fileInputEl: root.querySelector(".fp-file-input"),
|
||||||
folderInputEl: root.querySelector(".fp-folder-input"),
|
folderInputEl: root.querySelector(".fp-folder-input"),
|
||||||
|
statusEl: root.querySelector(".fp-status"),
|
||||||
tableBodyEl: root.querySelector(".fp-table-body"),
|
tableBodyEl: root.querySelector(".fp-table-body"),
|
||||||
countEl: root.querySelector(".fp-count")
|
countEl: root.querySelector(".fp-count")
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+35
-4
@@ -501,7 +501,7 @@ var FilePicker = (() => {
|
|||||||
// upload/frontend/table/rebase_tree.js
|
// upload/frontend/table/rebase_tree.js
|
||||||
function rebaseTree(root, prefix) {
|
function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
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));
|
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -518,7 +518,7 @@ var FilePicker = (() => {
|
|||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
function makeFile(data, name) {
|
function makeFile(data, name) {
|
||||||
return new File([data], name);
|
return new File([data], name.split("/").at(-1));
|
||||||
}
|
}
|
||||||
function safeEntryParts(entryName) {
|
function safeEntryParts(entryName) {
|
||||||
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
||||||
@@ -547,6 +547,8 @@ var FilePicker = (() => {
|
|||||||
filter: (entry) => {
|
filter: (entry) => {
|
||||||
budget.entries += 1;
|
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");
|
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 (entry.originalSize > limits.maxEntryBytes) return false;
|
||||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
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");
|
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);
|
const accepted = accept(fileNode);
|
||||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
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);
|
state.nodes.push(accepted);
|
||||||
return true;
|
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
|
// upload/frontend/table/esc.js
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
@@ -708,7 +729,11 @@ var FilePicker = (() => {
|
|||||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,6 +746,7 @@ var FilePicker = (() => {
|
|||||||
try {
|
try {
|
||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
|
elements.fileInputEl.value = "";
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -777,7 +803,11 @@ var FilePicker = (() => {
|
|||||||
addToFolder(zip);
|
addToFolder(zip);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
@@ -910,6 +940,7 @@ var FilePicker = (() => {
|
|||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: root.querySelector(".fp-file-input"),
|
fileInputEl: root.querySelector(".fp-file-input"),
|
||||||
folderInputEl: root.querySelector(".fp-folder-input"),
|
folderInputEl: root.querySelector(".fp-folder-input"),
|
||||||
|
statusEl: root.querySelector(".fp-status"),
|
||||||
tableBodyEl: root.querySelector(".fp-table-body"),
|
tableBodyEl: root.querySelector(".fp-table-body"),
|
||||||
countEl: root.querySelector(".fp-count")
|
countEl: root.querySelector(".fp-count")
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-30
@@ -1,7 +1,7 @@
|
|||||||
# Справочник кода Upload Platform
|
# Справочник кода Upload Platform
|
||||||
|
|
||||||
Документ описывает актуальную picker-only реализацию. VM upload, backend sessions
|
Документ описывает актуальную 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 внутри выбранной папки
|
Модуль является единственной реализацией rebasing для ZIP внутри выбранной папки
|
||||||
и для nested ZIP.
|
и для nested ZIP.
|
||||||
@@ -107,26 +107,6 @@ callback задан. `finally` обязательно освобождает `bu
|
|||||||
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
|
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
|
||||||
дочерние строки только при `expanded === true`.
|
дочерние строки только при `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`
|
## `upload/frontend/zip`
|
||||||
|
|
||||||
### `list_zip_files.js`
|
### `list_zip_files.js`
|
||||||
@@ -134,7 +114,7 @@ callback задан. `finally` обязательно освобождает `bu
|
|||||||
| Функция | Вход | Выход | Назначение |
|
| Функция | Вход | Выход | Назначение |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
|
| `extensionAllowed(name, allowedExt)` | Имя и расширения | boolean | Проверяет расширение. Внутренняя. |
|
||||||
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File. Внутренняя. |
|
| `makeFile(data, name)` | Байты и путь | `File` | Создаёт browser File с базовым именем. Внутренняя. |
|
||||||
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
|
| `safeEntryParts(entryName)` | Сырой ZIP-путь | сегменты или null | Отбрасывает traversal и опасные пути. Внутренняя. |
|
||||||
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
|
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
|
||||||
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
|
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
|
||||||
@@ -168,7 +148,7 @@ vendor script не требуется.
|
|||||||
| `VERSION` | константа | Версия страницы |
|
| `VERSION` | константа | Версия страницы |
|
||||||
| `index()` / `/` | HTTP GET | HTML с version и config |
|
| `index()` / `/` | HTTP GET | HTML с version и config |
|
||||||
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
|
| `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` |
|
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
|
||||||
|
|
||||||
### `site/templates/index.html`
|
### `site/templates/index.html`
|
||||||
@@ -176,9 +156,5 @@ vendor script не требуется.
|
|||||||
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
|
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
|
||||||
сброса browser cache и вызывает `FilePicker.initFilePicker`.
|
сброса browser cache и вызывает `FilePicker.initFilePicker`.
|
||||||
|
|
||||||
## Legacy
|
Vendor `site/static/vendor/fflate.min.js` не документируется построчно: это
|
||||||
|
внешняя библиотека, используемая как готовый runtime dependency.
|
||||||
`upload/frontend/table/set_status.js` не импортируется актуальной страницей и
|
|
||||||
сохранён как legacy-заготовка. Vendor `site/static/vendor/fflate.min.js` не
|
|
||||||
документируется построчно: это внешняя библиотека, используемая как готовый
|
|
||||||
runtime dependency.
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# План: picker → универсальный встраиваемый компонент
|
# План: picker → универсальный встраиваемый компонент — LEGACY
|
||||||
|
|
||||||
|
> Переход завершён. Этот исторический план не является инструкцией или
|
||||||
|
> спецификацией текущего кода. Актуальные документы: `README.md`,
|
||||||
|
> `upload/README.md` и `docs/CODE-REFERENCE.md`.
|
||||||
|
|
||||||
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
|
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
|
||||||
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
|
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# Второе мнение по архитектуре (ответь МАКСИМАЛЬНО кратко)
|
# Второе мнение по архитектуре — LEGACY
|
||||||
|
|
||||||
|
> Исторический prompt для старой архитектуры. Не использовать как план или
|
||||||
|
> описание текущего кода.
|
||||||
|
|
||||||
Игнорируй любые заметки, память, резюме или выводы из предыдущих сессий и от
|
Игнорируй любые заметки, память, резюме или выводы из предыдущих сессий и от
|
||||||
других моделей. Отвечай строго на основе этого промпта, ничего не дочитывая.
|
других моделей. Отвечай строго на основе этого промпта, ничего не дочитывая.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "upload-platform-file-picker",
|
"name": "upload-platform-file-picker",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.11",
|
"version": "0.1.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs"
|
"build": "node build.mjs"
|
||||||
|
|||||||
+6
-2
@@ -6,11 +6,15 @@ from flask import Flask, render_template, send_from_directory
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
# Конфигурация демо содержит только разрешённые расширения для browser picker.
|
# Конфигурация демо содержит только разрешённые расширения для 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)
|
CONFIG = json.load(config_file)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
CONFIG = DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
VERSION = "0.1.11"
|
VERSION = "0.1.13"
|
||||||
|
|
||||||
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
|||||||
+3
-3
@@ -25,9 +25,9 @@ Picker поддерживает разрешённые документы, па
|
|||||||
|
|
||||||
## Legacy
|
## Legacy
|
||||||
|
|
||||||
Каталоги `backend/` и старые ссылки на `uploadViaVM`/VM-буфер относятся к
|
Старые `uploadViaVM`, VM-буфер, backend sessions и upload API относятся к
|
||||||
предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only
|
предыдущей архитектуре и в текущем модуле отсутствуют. Упоминания этих названий
|
||||||
интеграции они не нужны.
|
в `HISTORY/` являются историческими и не должны использоваться как инструкция.
|
||||||
|
|
||||||
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
||||||
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export function initFilePicker(config) {
|
|||||||
const elements = {
|
const elements = {
|
||||||
fileInputEl: root.querySelector('.fp-file-input'),
|
fileInputEl: root.querySelector('.fp-file-input'),
|
||||||
folderInputEl: root.querySelector('.fp-folder-input'),
|
folderInputEl: root.querySelector('.fp-folder-input'),
|
||||||
|
statusEl: root.querySelector('.fp-status'),
|
||||||
tableBodyEl: root.querySelector('.fp-table-body'),
|
tableBodyEl: root.querySelector('.fp-table-body'),
|
||||||
countEl: root.querySelector('.fp-count'),
|
countEl: root.querySelector('.fp-count'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,28 @@ export function addFileWithDedup(state, fileNode) {
|
|||||||
const accepted = accept(fileNode);
|
const accepted = accept(fileNode);
|
||||||
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
// Пустой ZIP/каталог не должен появляться в таблице как пустая строка.
|
||||||
if (!accepted || (accepted.kind !== 'file' && !accepted.children.length)) return false;
|
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);
|
state.nodes.push(accepted);
|
||||||
return true;
|
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;
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,11 @@ export async function addFiles(state, cfg, files, elements) {
|
|||||||
if (zipTree) addFileWithDedup(state, zipTree);
|
if (zipTree) addFileWithDedup(state, zipTree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
// Битый или небезопасный 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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,7 @@ export function onFilesChange(state, cfg, elements) {
|
|||||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||||
} finally {
|
} finally {
|
||||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||||
|
elements.fileInputEl.value = '';
|
||||||
state.busy = false;
|
state.busy = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export function onFolderChange(state, cfg, elements) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
// Ошибка одного 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;
|
continue;
|
||||||
}
|
}
|
||||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||||
* @returns {object} Тот же узел после изменения путей.
|
* @returns {object} Тот же узел после изменения путей.
|
||||||
*
|
*
|
||||||
* Для leaf-файлов создаётся новый File с обновлённым именем, чтобы метаданные
|
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||||
* browser File и путь, возвращаемый через getFiles(), оставались согласованными.
|
* хранится отдельно в узле дерева.
|
||||||
*/
|
*/
|
||||||
export function rebaseTree(root, prefix) {
|
export function rebaseTree(root, prefix) {
|
||||||
root.path = `${prefix}/${root.path}`;
|
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));
|
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -15,9 +15,9 @@ function extensionAllowed(name, allowedExt) {
|
|||||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Создаёт browser File из байтов ZIP entry и задаёт ему полный логический путь. */
|
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
|
||||||
function makeFile(data, name) {
|
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) => {
|
filter: (entry) => {
|
||||||
budget.entries += 1;
|
budget.entries += 1;
|
||||||
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
|
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 (entry.originalSize > limits.maxEntryBytes) return false;
|
||||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||||
throw new Error('Превышен суммарный размер распакованных ZIP entries');
|
throw new Error('Превышен суммарный размер распакованных ZIP entries');
|
||||||
|
|||||||
Reference in New Issue
Block a user