Compare commits
2
Commits
3fd6f1cffc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52da9a3923 | ||
|
|
49bb4d70d8 |
@@ -3,7 +3,7 @@
|
||||
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
|
||||
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
|
||||
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
|
||||
- НЕЛЬЗЯ предполагать — сомневаешься = спроси
|
||||
- НИКОГДА не руководствоваться догадками. Если что-либо неизвестно точно или недоступно — немедленно остановиться и сообщить об этом; не продолжать действие и не выдавать предположение за факт.
|
||||
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
|
||||
|
||||
# ✅ ПОРЯДОК ПРАВКИ
|
||||
|
||||
@@ -18,11 +18,16 @@ htmlcov/
|
||||
*.log
|
||||
build/
|
||||
dist/
|
||||
!dist/
|
||||
!dist/file-picker.esm.js
|
||||
!dist/file-picker.iife.js
|
||||
*.egg-info/
|
||||
# TMP/
|
||||
# about1500.md
|
||||
*.har
|
||||
chrome-net-export-log.json
|
||||
History/tests/2026-09-15-*.md
|
||||
tests/e2e_mass_processing.py
|
||||
TSTFILES/
|
||||
files/
|
||||
upload-platform/
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
| [infra/](infra/) | Инфраструктура: кластер, ingress, RST-диагностика, HTTP/2, ВМ-буфер |
|
||||
| [ux-frontend/](ux-frontend/) | Фичи фронтенда: таймеры, имена файлов, ZIP-раскрытие, лимиты, прерывание/ETA |
|
||||
| [upload/](upload/) | Загрузка файлов: диагностика, лимиты, стресс-тесты |
|
||||
| [upload-integration/](upload-integration/) | Интеграция переиспользуемого модуля upload-platform |
|
||||
| [code-fixes/](code-fixes/) | Исправления кода по версиям (баги, оптимизация, безопасность, логирование) |
|
||||
| [tests/](tests/) | Тесты и бенчмарки: замеры UI, обфускация, нагрузка |
|
||||
|
||||
@@ -122,6 +123,11 @@
|
||||
- **2026-08-23-upload-stress-test.md** — тесты загрузки и стресс (v0.0.59–0.0.60, кластер naeel-test-3).
|
||||
- **2026-08-24-upload-limits.md** — v0.0.62: лимиты 50 МБ на файл, 500 МБ на сессию.
|
||||
|
||||
## upload-integration/ — интеграция платформы загрузки
|
||||
|
||||
- [README.md](upload-integration/README.md) — стандарт и журнал процесса интеграции `upload-platform`.
|
||||
- [2026-09-15-upload-platform-integration-plan.md](upload-integration/2026-09-15-upload-platform-integration-plan.md) — детальный архитектурный план и этапы интеграции версии v0.2.2 в drhider.
|
||||
|
||||
## code-fixes/ — исправления кода (по версиям)
|
||||
|
||||
- **2026-08-31-vm-sync-readme.md** — документирован `rsync` текущего проекта на ВМ
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# План интеграции upload-platform (v0.2.2) в drhider
|
||||
|
||||
**Дата:** 2026-09-15
|
||||
**Статус:** План составлен, ожидает команды на выполнение
|
||||
**Целевой сервис:** drhider (Managed Flask на платформе Штурвал)
|
||||
**Источник модуля:** `upload-platform/` (релиз v0.2.2, commit `df0576a`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и цели
|
||||
|
||||
В `drhider` (v0.0.77) модуль загрузки `upload/` зафиксирован в промежуточном состоянии:
|
||||
- Фронтенд Слоя 1 использует устаревшую табличную вёрстку с ручным парсингом ZIP без поддержки древовидного сворачивания.
|
||||
- Слой 2 работает по смешанной схеме, в то время как в `upload-platform` v0.2.2 реализован и протестирован чистый пофайловый транзит (Streaming transit: PUT $\to$ POST `/api/upload_refs` $\to$ исходящий pull в RAM $\to$ немедленный DELETE $\to$ repeat).
|
||||
|
||||
**Цель интеграции:**
|
||||
Заменить устаревшие наработки в `upload/` и `site/templates/index.html` на проверенную автономную версию `upload-platform` v0.2.2 без нарушения бизнес-логики обфускации (Слой 3: LLM, SSE-стриминг, генерация ZIP).
|
||||
|
||||
---
|
||||
|
||||
## 2. Границы ответственности слоёв
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ БРАУЗЕР │
|
||||
│ [ Слой 1: FilePicker ] ──► [ Слой 2: uploadViaVM ] │
|
||||
└─────────────────────────────────────┬──────────────────────────────────┘
|
||||
│ 1. PUT файл
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ ВМ-буфер (RAM WebDAV) │
|
||||
└──────────────┬──────────────┘
|
||||
│ 3. Исходящий GET (pull в RAM)
|
||||
│ 4. DELETE с ВМ
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ FLASK BACKEND │
|
||||
│ [ Слой 2: Blueprint upload_refs ] ──► [ RAM-сессия: add_file ] │
|
||||
│ │ │
|
||||
│ [ Слой 3: drhider engine ] ◄─────────────────────┘ get_files(sid) │
|
||||
│ (SSE-прогресс, обфускация, mapping, download) │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Слой 1 (Фронтенд)**: инициализация через `FilePicker.initFilePicker()`. Полностью изолирован от транспорта. Возвращает список объектов `File` через `picker.getFiles()`.
|
||||
- **Слой 2 (Фронтенд + Бэкенд)**: `uploadViaVM` передаёт файлы по одному через буфер на ВМ в RAM сессии Flask.
|
||||
- **Слой 3 (drhider)**: логика обфускации забирает готовые файлы из `upload.backend.session.get_files(sid)` и запускает существующий пайплайн (`/api/drhider/<sid>`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Этапы интеграции
|
||||
|
||||
### Этап 1. Синхронизация модуля ядра (`upload/`)
|
||||
1. Заменить содержимое `upload/` в корне `drhider` на актуальную версию из `upload-platform/upload/`:
|
||||
- `upload/backend/`: актуальные `upload_refs/` (поддержка `httpxTransport`, безопасный pull, санитизация `safe_name`) и `session/` (хранилище в RAM, TTL, блокировки).
|
||||
- `upload/frontend/`: чистые ES-модули (`index.js`, `table/`, `upload/`, `zip/` на базе `fflate`).
|
||||
2. Скопировать готовые бандлы из `upload-platform/dist/` в `site/static/dist/` (или настроить прямую раздачу из `upload/frontend` через `main_bp.py`).
|
||||
|
||||
### Этап 2. Проверка и настройка бэкенда (`site/routes/`)
|
||||
1. Проверить регистрацию Blueprint в [site/routes/__init__.py](site/routes/__init__.py):
|
||||
```python
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
"apiPrefix": "/api",
|
||||
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
|
||||
"maxFileBytes": 50 * 1024 * 1024,
|
||||
"maxSessionBytes": 500 * 1024 * 1024,
|
||||
"ttlSeconds": 30 * 60,
|
||||
"pullRetries": 3,
|
||||
"pullRetryDelay": 2,
|
||||
}))
|
||||
```
|
||||
2. Убедиться, что [site/routes/api_bp.py](site/routes/api_bp.py) бесшовно использует сессии из обновлённого `upload.backend.session`.
|
||||
|
||||
### Этап 3. Обновление пользовательского интерфейса (`site/templates/index.html`)
|
||||
1. Заменить устаревшую ручную вёрстку таблицы и инпутов на контейнер для монтирования Слой 1:
|
||||
```html
|
||||
<div id="file-picker-container"></div>
|
||||
```
|
||||
2. Подключить модуль `initFilePicker` и сконфигурировать фильтрацию по `.pdf, .doc, .docx, .txt, .md`.
|
||||
3. Модифицировать обработчик кнопки «🛡️ Обфусцировать»:
|
||||
- Вызов `uploadViaVM(files, { vmUploadUrl, backendUploadUrl, onFileStatus, onProgress })`.
|
||||
- По завершении загрузки всех файлов — автоматический переход к Стек/Фазе 2: запуск SSE-стрима `/api/drhider/<sid>`.
|
||||
4. Сохранить существующий трёхсекционный рендер обработки (Готово / Обрабатывается / Ожидают) и индикацию ETA/статистики LLM.
|
||||
|
||||
### Этап 4. Тестирование и валидация
|
||||
1. **Автономные тесты модуля `upload`**:
|
||||
- Запуск pytest для `upload/` внутри `drhider`.
|
||||
2. **Интеграционные тесты `drhider`**:
|
||||
- Запуск существующих тестов `tests/test_builder.py`, `tests/test_extractor.py`, `tests/test_replacer.py`, `tests/test_scanner.py`.
|
||||
3. **Ручная/Smoke проверка сквозного цикла**:
|
||||
- Выбор одиночного документа, папки и архива ZIP.
|
||||
- Проверка пофайловой загрузки через буфер и немедленного удаления с ВМ.
|
||||
- Проверка успешного прохождения двухпроходной обфускации и скачивания результирующего архива.
|
||||
|
||||
---
|
||||
|
||||
## 4. Контрольные точки (Definition of Done)
|
||||
- [ ] Все тесты `pytest tests/` завершаются успешно (PASS).
|
||||
- [ ] Отсутствуют временные файлы на диске (всё в RAM).
|
||||
- [ ] Не нарушена логика отмены (`cancel`) и работы со стримами SSE.
|
||||
- [ ] Сборка и синтаксис проверены через `py_compile`.
|
||||
- [ ] Документ с отчётом о завершении зафиксирован в `History/upload-integration/`.
|
||||
@@ -0,0 +1,46 @@
|
||||
# 2026-09-15: Интеграция upload-platform v0.2.2 в drhider
|
||||
|
||||
## 1. Цель и контекст
|
||||
|
||||
Интеграция протестированного модульного релиза `upload-platform` v0.2.2 в сервис `drhider`.
|
||||
Версия сервиса повышена: `v0.0.77` $\rightarrow$ `v0.0.78`.
|
||||
|
||||
## 2. Выполненные изменения
|
||||
|
||||
### Ядро модуля `upload/`
|
||||
- **Слой 1 (Frontend)**: переход на модульный `initFilePicker` с распаковкой ZIP через `fflate`, древовидным представлением папок и архивов, встроенным сворачиванием/разворачиванием групп и дедупликацией. Устаревшие ручные парсеры удалены.
|
||||
- **Слой 2 (Transit Upload)**:
|
||||
- **Frontend**: пофайловый стриминговый транзит `uploadViaVM` (PUT на ВМ $\rightarrow$ POST `/api/upload_refs` $\rightarrow$ исходящий pull $\rightarrow$ DELETE из буфера $\rightarrow$ повтор для следующего файла).
|
||||
- **Backend**: обновлён Blueprint `create_upload_refs_blueprint`: добавлена поддержка локальных путей/mock-буфера (`is_path_only_prefix`), кастомного `httpxTransport` для безопасного тестирования, возвращение параметра `added`.
|
||||
- **Session**: in-memory хранилище сессий с потокобезопасным TTL и проверкой лимитов.
|
||||
|
||||
### Приложение Flask и маршруты
|
||||
- В [site/routes/main_bp.py](site/routes/main_bp.py) добавлены маршруты раздачи бандлов: `/file-picker/<path:filename>` и `/dist/<path:filename>`.
|
||||
- Бандлы `file-picker.iife.js` и `file-picker.esm.js` размещены в `dist/` и `site/static/dist/`.
|
||||
|
||||
### Пользовательский интерфейс ([site/templates/index.html](site/templates/index.html))
|
||||
- Вёрстка выбора файлов заменена контейнером `#filePicker`.
|
||||
- Инициализирован `FilePicker.initFilePicker` с разрешёнными расширениями (`.pdf`, `.doc`, `.docx`, `.txt`, `.md`).
|
||||
- Логика кнопки «🛡️ Обфусцировать» переведена на `FilePicker.uploadViaVM` с поддержкой отмены (`AbortController`) и пофайловым отображением статусов.
|
||||
- Сохранён и адаптирован этап 2 обработки (SSE `/api/process_stream/<sid>`, тикеры времени, статистика LLM, выгрузка ZIP и CSV).
|
||||
|
||||
### Тесты
|
||||
- Скопированы и адаптированы тесты:
|
||||
- [tests/test_hardening.py](tests/test_hardening.py) (проверка устойчивости, malformed requests, SSRF, traversal).
|
||||
- [tests/test_safe_name.py](tests/test_safe_name.py) (санитизация путей).
|
||||
- Обновлён [tests/test_upload.py](tests/test_upload.py) (длина сессии `uuid4().hex` = 32 символа).
|
||||
|
||||
## 3. Проверки и верификация
|
||||
|
||||
- `pytest tests/`: **39 passed** в 4 тестовых файлах.
|
||||
- `python3 tests/test_builder.py`: **20/20 PASS**.
|
||||
- `python3 tests/test_extractor.py`: **10/10 PASS**.
|
||||
- `python3 tests/test_replacer.py`: **10/10 PASS**.
|
||||
- `python3 tests/test_scanner.py`: **20/20 PASS**.
|
||||
- `python3 tests/test_zip.py`: **28/28 PASS**.
|
||||
- Проверка синтаксиса `py_compile` по всем `.py` файлам проекта: **ALL PY FILES COMPILED OK**.
|
||||
- Проверка импортов ядра: `from drhider import obfuscate_files, LLMClient` $\rightarrow$ **OK**.
|
||||
- Проверка HTTP:
|
||||
- `GET /` $\rightarrow$ **HTTP 200**, HTML отрендерен (28571 байт).
|
||||
- `GET /health` $\rightarrow$ **HTTP 200**, `{"ok":true,"version":"0.0.78"}`.
|
||||
- `GET /file-picker/file-picker.iife.js` $\rightarrow$ **HTTP 200** (44465 байт).
|
||||
@@ -0,0 +1,21 @@
|
||||
# History / upload-integration — Журнал интеграции upload-platform
|
||||
|
||||
В этом каталоге фиксируется полный жизненный цикл интеграции автономного модуля
|
||||
`upload-platform` (Слой 1: File Picker + Слой 2: пофайловый транзит через ВМ-буфер)
|
||||
в сервис `drhider`.
|
||||
|
||||
## Стандарт ведения документации по интеграции
|
||||
|
||||
Каждый этап или значимый результат документируется отдельным файлом по формату:
|
||||
`YYYY-MM-DD-<краткое-действие>.md`
|
||||
|
||||
Обязательные разделы каждого документа:
|
||||
1. **Цель и контекст**: что делалось, версии компонентов (`drhider`, `upload-platform`).
|
||||
2. **Выполненные изменения**: затронутые файлы и границы слоёв (Слой 1, Слой 2, Слой 3).
|
||||
3. **Проверки и тесты**: команды запуска тестов и результаты (PASS/FAIL).
|
||||
4. **Обнаруженные нюансы/отклонения**: проблемы обратной совместимости, сетевые или платформенные особенности.
|
||||
|
||||
## Документы раздела
|
||||
|
||||
- [2026-09-15-upload-platform-integration-plan.md](2026-09-15-upload-platform-integration-plan.md) — детальный архитектурный план и этапы интеграции версии v0.2.2 в drhider.
|
||||
- [2026-09-15-upload-platform-v022-integration-report.md](2026-09-15-upload-platform-v022-integration-report.md) — отчёт об успешной интеграции и верификации v0.2.2 (версия drhider v0.0.78).
|
||||
Vendored
+1194
File diff suppressed because it is too large
Load Diff
Vendored
+1218
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
|
||||
sys.path.insert(0, _sys_path_root)
|
||||
|
||||
# Версия приложения (меняется при изменениях)
|
||||
VERSION = "0.0.77"
|
||||
VERSION = "0.0.78"
|
||||
|
||||
|
||||
def setup_logging():
|
||||
|
||||
+15
-3
@@ -13,9 +13,9 @@ from flask import Blueprint, render_template, current_app, send_from_directory
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Корень модуля upload/frontend — для раздачи ES-модулей браузеру
|
||||
_UPLOAD_FRONTEND_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"upload", "frontend")
|
||||
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_UPLOAD_FRONTEND_DIR = os.path.join(_ROOT_DIR, "upload", "frontend")
|
||||
_DIST_DIR = os.path.join(_ROOT_DIR, "dist")
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
@@ -34,3 +34,15 @@ def index():
|
||||
def upload_frontend(filename):
|
||||
"""Раздаёт ES-модули переиспользуемого слоя загрузки (upload/frontend)."""
|
||||
return send_from_directory(_UPLOAD_FRONTEND_DIR, filename)
|
||||
|
||||
|
||||
@main_bp.route("/file-picker/<path:filename>")
|
||||
def file_picker_dist(filename):
|
||||
"""Отдаёт собранный бандл из каталога dist."""
|
||||
return send_from_directory(_DIST_DIR, filename)
|
||||
|
||||
|
||||
@main_bp.route("/dist/<path:filename>")
|
||||
def dist_files(filename):
|
||||
"""Отдаёт файлы из каталога dist."""
|
||||
return send_from_directory(_DIST_DIR, filename)
|
||||
|
||||
Vendored
+1194
File diff suppressed because it is too large
Load Diff
Vendored
+1218
File diff suppressed because it is too large
Load Diff
+874
-589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
import threading
|
||||
from time import monotonic
|
||||
|
||||
import httpx
|
||||
from flask import Flask
|
||||
|
||||
from upload.backend.session import add_file, cleanup, configure, create_session, get_files
|
||||
from upload.backend.session import state as session_state
|
||||
from upload.backend.upload_refs.blueprint import create_upload_refs_blueprint
|
||||
|
||||
|
||||
def build_app(transport, **config):
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.config['UPLOAD_HTTPX_TRANSPORT'] = transport
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
'vmUploadPrefix': 'https://vm.test/buffer/',
|
||||
'httpxTransport': transport,
|
||||
'pullRetries': 3,
|
||||
'pullRetryDelay': 0,
|
||||
**config,
|
||||
}))
|
||||
return app
|
||||
|
||||
|
||||
def test_session_limit_accepts_exact_boundary_and_rejects_overflow():
|
||||
sid = create_session()
|
||||
previous = session_state.MAX_SESSION_BYTES
|
||||
configure(max_session_bytes=10)
|
||||
try:
|
||||
assert add_file(sid, 'exact.bin', b'x' * 10) is True
|
||||
assert add_file(sid, 'overflow.bin', b'x') is False
|
||||
assert len(get_files(sid)) == 1
|
||||
finally:
|
||||
configure(max_session_bytes=previous)
|
||||
cleanup(sid)
|
||||
|
||||
|
||||
def test_upload_refs_rejects_actual_oversize_and_deletes_buffer():
|
||||
storage = {'oversize': b'123456789'}
|
||||
deleted = []
|
||||
|
||||
def handler(request):
|
||||
if request.method == 'GET':
|
||||
return httpx.Response(200, content=storage['oversize'])
|
||||
if request.method == 'DELETE':
|
||||
deleted.append(request.url.path)
|
||||
storage.clear()
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(405)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
app = build_app(transport, maxFileBytes=8)
|
||||
sid = create_session()
|
||||
try:
|
||||
with app.test_client() as client:
|
||||
response = client.post('/api/upload_refs', json={
|
||||
'session': sid,
|
||||
'files': [{
|
||||
'name': 'oversize.txt',
|
||||
'size': 8,
|
||||
'url': 'https://vm.test/buffer/oversize',
|
||||
}],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['added'] == 0
|
||||
assert get_files(sid) == []
|
||||
assert deleted == ['/buffer/oversize']
|
||||
assert storage == {}
|
||||
finally:
|
||||
cleanup(sid)
|
||||
|
||||
|
||||
def test_upload_refs_retries_transient_pull_and_deletes_after_success():
|
||||
attempts = []
|
||||
deleted = []
|
||||
|
||||
def handler(request):
|
||||
if request.method == 'GET':
|
||||
attempts.append(request.url.path)
|
||||
if len(attempts) == 1:
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, content=b'retry-content')
|
||||
if request.method == 'DELETE':
|
||||
deleted.append(request.url.path)
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(405)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
app = build_app(transport)
|
||||
with app.test_client() as client:
|
||||
response = client.post('/api/upload_refs', json={'files': [{
|
||||
'name': 'retry.txt',
|
||||
'size': 13,
|
||||
'url': 'https://vm.test/buffer/retry',
|
||||
}]})
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['added'] == 1
|
||||
assert len(attempts) == 2
|
||||
assert deleted == ['/buffer/retry']
|
||||
cleanup(data['session'])
|
||||
|
||||
|
||||
def test_session_ttl_removes_session():
|
||||
previous = session_state.TTL_SECONDS
|
||||
configure(ttl_seconds=0.05)
|
||||
sid = create_session()
|
||||
try:
|
||||
deadline = monotonic() + 1
|
||||
while monotonic() < deadline and get_files(sid) is not None:
|
||||
threading.Event().wait(0.01)
|
||||
assert get_files(sid) is None
|
||||
finally:
|
||||
configure(ttl_seconds=previous)
|
||||
cleanup(sid)
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from upload.backend.upload_refs.safe_name import safe_name
|
||||
|
||||
|
||||
def test_safe_name_simple():
|
||||
assert safe_name("test.txt") == "test.txt"
|
||||
assert safe_name("folder/subfolder/file.pdf") == "folder/subfolder/file.pdf"
|
||||
assert safe_name("folder\\subfolder\\file.pdf") == "folder/subfolder/file.pdf"
|
||||
|
||||
|
||||
def test_safe_name_traversal():
|
||||
assert safe_name("../etc/passwd") == ""
|
||||
assert safe_name("folder/../../etc/passwd") == ""
|
||||
assert safe_name("/root/file.txt") == "root/file.txt"
|
||||
assert safe_name("..") == ""
|
||||
assert safe_name("") == ""
|
||||
assert safe_name(None) == ""
|
||||
@@ -75,7 +75,7 @@ def test_single_file():
|
||||
assert rv.status_code == 200
|
||||
resp = json.loads(rv.data)
|
||||
assert resp["ok"] is True
|
||||
assert len(resp["session"]) == 12
|
||||
assert len(resp["session"]) == 32
|
||||
assert resp["count"] == 1
|
||||
|
||||
|
||||
|
||||
+26
-178
@@ -1,183 +1,31 @@
|
||||
# upload — переиспользуемые слои загрузки через ВМ
|
||||
# Upload module (v0.2.0)
|
||||
|
||||
Два самодостаточных слоя для выноса в любой другой проект БЕЗ изменения кода
|
||||
(меняется только конфиг). Поведение 1:1 с drhider v0.0.75.
|
||||
Переиспользуемый модуль загрузки, состоящий из двух независимых слоёв:
|
||||
|
||||
```
|
||||
upload/
|
||||
frontend/
|
||||
zip/ # распаковка ZIP (чистые функции)
|
||||
table/ # слой 1: выбор файлов/папки/архива, дедуп, статусы, таблица
|
||||
upload/ # слой 2 (фронт): PUT на ВМ + POST /api/upload_refs
|
||||
backend/
|
||||
upload_refs/ # слой 2 (бэк): Blueprint upload_refs (SSRF, _safe_name, ретраи)
|
||||
session/ # in-memory сессия с TTL и лимитами
|
||||
config.example.json
|
||||
```
|
||||
1. **Слой 1 (`upload/frontend/`)** — File Picker:
|
||||
- IIFE: `FilePicker.initFilePicker(config)`;
|
||||
- ESM: `import { initFilePicker } from './file-picker.esm.js'` (или из `upload/frontend/index.js`).
|
||||
- Получение выбранных файлов: `picker.getFiles()` возвращает плоский массив `{ path, name, size, file }`.
|
||||
- Поддерживает выбор файлов, папок, клиентскую распаковку ZIP (fflate) и проверку лимитов.
|
||||
|
||||
## Что это
|
||||
2. **Слой 2 (`upload/frontend/upload/` + `upload/backend/`)** — Пофайловый транзит в RAM сессии:
|
||||
- Фронтенд: `uploadViaVM(files, options)` или `FilePicker.uploadViaVM(files, options)`:
|
||||
- Пофайловый стриминг: для каждого файла выполняется `PUT` в буфер на ВМ, немедленный `POST /api/upload_refs` в бэкенд, и после подтверждения приёма буфер на ВМ очищается.
|
||||
- Поддержка `AbortSignal` для отмены загрузки на любом этапе.
|
||||
- Бэкенд: `create_upload_refs_blueprint(cfg)`:
|
||||
- Приём ссылок через исходящий потоковый `GET` в память RAM.
|
||||
- SSRF-валидация префикса буфера (`vmUploadPrefix`).
|
||||
- Защита от path traversal (`safe_name`).
|
||||
- Хранилище сессий в оперативной памяти (`upload/backend/session`).
|
||||
- Никакой записи на диск.
|
||||
|
||||
| Слой | Где | Ответственность |
|
||||
|---|---|---|
|
||||
| **1. Выбор файлов** | фронт | таблица, дедуп, раскрытие ZIP, путь, статусы, кнопки |
|
||||
| **2. Закачка через ВМ** | фронт + бэк | PUT на ВМ-буфер (фронт) → `upload_refs` pull (бэк) → сессия |
|
||||
| **3. Логика приложения** | — | у каждого приложения своя (обфускация, SSE и т.п.). В модуле её НЕТ |
|
||||
## Интеграция в сторонний проект
|
||||
|
||||
Паттерн (зачем ВМ): шлюз managed-кластера рвёт тела >64КБ, egress не ограничен.
|
||||
Поэтому: браузер → `PUT` на ВМ-буфер → Flask `POST /api/upload_refs` → egress `GET` → сессия.
|
||||
|
||||
---
|
||||
|
||||
## Подключение фронта
|
||||
|
||||
Подключить ES-модули (`<script type="module">`) и собрать слой 1:
|
||||
|
||||
```js
|
||||
import { initUploadTable } from './upload/frontend/table/init_upload_table.js';
|
||||
import { uploadViaVM } from './upload/frontend/upload/upload_via_vm.js';
|
||||
|
||||
const cfg = {
|
||||
allowedExt: ['.pdf', '.doc', '.docx', '.txt', '.md'],
|
||||
maxFileBytes: 50 * 1024 * 1024,
|
||||
maxSessionBytes: 500 * 1024 * 1024,
|
||||
estMbSec: 12,
|
||||
};
|
||||
|
||||
const table = initUploadTable(cfg, {
|
||||
fileInput: document.getElementById('fileInput'), // <input type="file" multiple>
|
||||
folderInput: document.getElementById('folderInput'), // <input webkitdirectory>
|
||||
tableBody: document.getElementById('fileList'), // <tbody>
|
||||
countEl: document.getElementById('fileCount'),
|
||||
uploadBtnEl: document.getElementById('uploadBtn'),
|
||||
onStatus(cls, text) { /* сообщения (cls: ''|'progress'|'done'|'error') */ },
|
||||
});
|
||||
|
||||
// Слой 2 — закачка учитываемых файлов на ВМ:
|
||||
// idxInSf[k] — индекс k-го отправляемого файла в строках таблицы (своя логика маппинга)
|
||||
const res = await uploadViaVM(toSend, cfg.vmUploadUrl, {
|
||||
session: currentSid,
|
||||
onStatus(k, html) { table.setStatus(idxInSf[k], html); }, // прогресс → ячейка таблицы
|
||||
onUploadStatus(text) { /* статусная строка */ },
|
||||
onXHR(xhr) { activeXHR = xhr; }, // регистрация активного PUT для отмены (abort)
|
||||
});
|
||||
// res = {ok:true, session, count} | {ok:false, error}
|
||||
// После этого у вас в сессии res.session лежат файлы — запускайте СВОЮ обработку.
|
||||
```
|
||||
|
||||
API слоя 1 (`initUploadTable(cfg, els)` → `table`):
|
||||
- `addFiles(File[])` — дедуп + раскрытие ZIP + фильтр + лимиты;
|
||||
- `getFiles()` → `[{name, size, file}]` — **только учитываемые** (без сверхлимитных);
|
||||
- `getOverNames()` → `Set` — имена сверх лимита;
|
||||
- `setStatus(idx, html)` — статус в ячейке таблицы;
|
||||
- `render()` — перерисовать таблицу;
|
||||
- `clear()` — очистить список;
|
||||
- `setBusy(bool)` — заблокировать список на время загрузки/обработки;
|
||||
- `state` — доступ к состоянию (для слоя 3: установить `state.proc` для 3-секционной таблицы).
|
||||
|
||||
---
|
||||
|
||||
## Подключение бэка
|
||||
|
||||
```python
|
||||
from flask import Flask
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
from upload.backend.session import create_session, add_file, get_files
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(create_upload_refs_blueprint({
|
||||
"apiPrefix": "/api", # префикс эндпоинтов
|
||||
"vmUploadPrefix": "https://.../drhider-upload/", # доверенный префикс (SSRF)
|
||||
"maxFileBytes": 50 * 1024 * 1024,
|
||||
"maxSessionBytes": 500 * 1024 * 1024,
|
||||
"ttlSeconds": 1800,
|
||||
"pullRetries": 3,
|
||||
"pullRetryDelay": 2,
|
||||
}))
|
||||
```
|
||||
|
||||
Эндпоинт: `POST {apiPrefix}/upload_refs` — принимает JSON
|
||||
`{"session": "...", "files": [{"name", "size", "url"}]}`, тянет каждый файл с ВМ
|
||||
(SSRF-валидация по `vmUploadPrefix`, `_safe_name`, ретраи), кладёт в сессию.
|
||||
Возвращает `{"ok": true, "session", "count"}`.
|
||||
|
||||
Сессия: `create_session()` → sid; `add_file(sid, name, content)` (лимит 500МБ);
|
||||
`get_files(sid)` → `[(name, bytes), ...]` или `None`. TTL 30 мин (таймер в фоне).
|
||||
|
||||
---
|
||||
|
||||
## Раздача модуля в Flask (обязательно)
|
||||
|
||||
Фронт-модули — ES-модули, браузер грузит их по HTTP (не через file://).
|
||||
Добавьте route, который отдаёт папку `upload/frontend` (как в drhider `site/routes/main_bp.py`):
|
||||
|
||||
```python
|
||||
# в любом blueprint приложения
|
||||
import os
|
||||
from flask import send_from_directory
|
||||
|
||||
_UPLOAD_FRONTEND = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||
"upload", "frontend")
|
||||
|
||||
@bp.route("/upload/<path:filename>")
|
||||
def upload_frontend(filename):
|
||||
return send_from_directory(_UPLOAD_FRONTEND, filename)
|
||||
```
|
||||
|
||||
Тогда импорты в браузере: `import { initUploadTable } from '/upload/table/init_upload_table.js';`.
|
||||
|
||||
> Если фронт-модули хостятся отдельно (другой домен/приложение) — путь `/upload/...`
|
||||
> и CORS настраиваются под ваш случай (правка приложения/nginx, не кода модуля).
|
||||
|
||||
---
|
||||
|
||||
## Слой 3: как подключить обработку (опционально)
|
||||
|
||||
Модуль отдаёт файлы в сессию, а обрабатываете их ВЫ (обфускация, конвертация, ...).
|
||||
После `uploadViaVM` файлы лежат в `res.session`:
|
||||
|
||||
```python
|
||||
files = get_files(res.session) # [(name, bytes), ...]
|
||||
# ... ваша обработка ...
|
||||
store_result(res.session, result_zip) # если нужно отдать результат обратно
|
||||
```
|
||||
|
||||
Для 3-секционной таблицы прогресса (готово/текущий/ожидают) модуль предоставляет
|
||||
`renderProcTable` — достаточно дать слою 3 доступ к `table.state.proc`:
|
||||
|
||||
```js
|
||||
table.state.proc = { phase: 'processing', procState: {}, procExtractRate: null };
|
||||
function syncProcCtx() {
|
||||
table.state.proc.phase = phase;
|
||||
table.state.proc.procState = procState; // {idx: {st, elapsed, eta, chars, t0}}
|
||||
table.state.proc.procExtractRate = rate; // сек/МБ (для оценок ожидающих)
|
||||
}
|
||||
syncProcCtx();
|
||||
table.render(); // сам выберет renderProcTable при phase==='processing'
|
||||
```
|
||||
|
||||
Статусы в ячейках: `table.setStatus(idx, html)`.
|
||||
|
||||
---
|
||||
|
||||
## Конфиг (слой 0)
|
||||
|
||||
Всё drhider-специфичное задаётся конфигом, а не кодом слоёв:
|
||||
|
||||
| Поле | Назначение |
|
||||
|---|---|
|
||||
| `vmUploadUrl` | базовый URL ВМ-буфера для PUT (фронт) |
|
||||
| `vmUploadPrefix` | тот же префикс для SSRF-валидации (бэк) |
|
||||
| `allowedExt` | расширения документов из папки/архивов |
|
||||
| `maxFileBytes` / `maxSessionBytes` | лимиты 50 МБ / 500 МБ |
|
||||
| `apiPrefix` | префикс Blueprint `/api` |
|
||||
| `pullRetries` / `pullRetryDelay` | ретраи pull (3 × 2с) |
|
||||
| `pullTimeout` | таймаут одного GET pull (сек) |
|
||||
| `ttlSeconds` | TTL сессии (по умолчанию 1800) |
|
||||
| `estMbSec` | оценка времени обработки, сек/МБ (только UI) |
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ трогать
|
||||
|
||||
- Слой 3 — логика приложения (обработка файлов из сессии, SSE-прогресс) у каждого своя.
|
||||
- CORS на ВМ-буфере — если домен приложения другой, правится nginx на ВМ, а не код модуля.
|
||||
1. Скопировать каталог `upload/` в проект.
|
||||
2. Во Flask подключить Blueprint:
|
||||
```python
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
app.register_blueprint(create_upload_refs_blueprint({"vmUploadPrefix": "..."}))
|
||||
```
|
||||
3. На фронтенде подключить `file-picker.iife.js` или импортировать ESM.
|
||||
4. Вызвать `initFilePicker`, а при отправке вызвать `uploadViaVM(picker.getFiles(), options)`.
|
||||
|
||||
+4
-7
@@ -1,8 +1,5 @@
|
||||
"""Переиспользуемый модуль загрузки через ВМ (2 слоя).
|
||||
"""Переиспользуемые слои загрузки файлов.
|
||||
|
||||
Структура:
|
||||
- frontend/ — слой 1 (выбор файлов) + слой 2 (закачка через ВМ), JS.
|
||||
- backend/ — слой 2 (бэк): Blueprint upload_refs + in-memory сессия, Python.
|
||||
|
||||
Подключение в новый проект — см. README.md.
|
||||
"""
|
||||
Пакет не выполняет импортов и не запускает побочных действий при загрузке.
|
||||
Рабочая picker-only логика находится в подпакетах ``frontend`` и ``zip``.
|
||||
"""
|
||||
@@ -1,6 +1,6 @@
|
||||
"""In-memory хранилище сессий с TTL и лимитами.
|
||||
|
||||
Каждая операция — в отдельном файле (см. ниже), общее состояние — в state.py.
|
||||
Каждая операция — в отдельном файле, общее состояние — в state.py.
|
||||
Импорт как единый пакет:
|
||||
|
||||
from upload.backend.session import create_session, add_file, get_files
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
"""Слой 2 (бэк): переиспользуемый Blueprint закачки через ВМ.
|
||||
|
||||
Использование:
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
app.register_blueprint(create_upload_refs_blueprint(cfg))
|
||||
"""
|
||||
"""upload_refs — Flask Blueprint для приёма ссылок и pull с ВМ-буфера."""
|
||||
|
||||
from .blueprint import create_upload_refs_blueprint
|
||||
from .safe_name import safe_name
|
||||
from .pull_file import pull_file
|
||||
from .config import PULL_RETRIES, PULL_RETRY_DELAY, VM_UPLOAD_PREFIX
|
||||
|
||||
__all__ = [
|
||||
"create_upload_refs_blueprint",
|
||||
"safe_name",
|
||||
"pull_file",
|
||||
"PULL_RETRIES",
|
||||
"PULL_RETRY_DELAY",
|
||||
"VM_UPLOAD_PREFIX",
|
||||
]
|
||||
__all__ = ["create_upload_refs_blueprint", "safe_name", "pull_file"]
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""Переиспользуемый Blueprint слоя 2: POST /upload_refs (pull с ВМ-буфера в сессию).
|
||||
|
||||
Поведение 1:1 с drhider v0.0.75 (site/routes/api_bp.py):
|
||||
- _safe_name (path traversal)
|
||||
- SSRF-валидация url.startswith(VM_UPLOAD_PREFIX)
|
||||
- лимит на один файл -> delete+skip
|
||||
- pull с ретраями
|
||||
Поведение:
|
||||
- safe_name (защита от path traversal)
|
||||
- SSRF-валидация url.startswith(vm_prefix)
|
||||
- лимит на один файл -> delete + skip
|
||||
- pull с ретраями через httpx stream
|
||||
- лимит сессии -> skip; отсутствие сессии -> 404
|
||||
- delete url с ВМ (best-effort)
|
||||
- поддержка пофайлового транзита и пачек
|
||||
- опциональный callback для интеграции/эмуляции Слоя 3 (on_file_received)
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
from urllib.parse import urlsplit
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
|
||||
from ..session import (create_session, add_file, get_files, file_count,
|
||||
MAX_FILE_BYTES, configure)
|
||||
@@ -22,7 +25,7 @@ from .pull_file import pull_file
|
||||
log = logging.getLogger("upload.upload_refs")
|
||||
|
||||
|
||||
def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
def create_upload_refs_blueprint(cfg: dict = None) -> Blueprint:
|
||||
"""Создать Blueprint с эндпоинтом upload_refs.
|
||||
|
||||
cfg (все ключи опциональны, есть дефолты):
|
||||
@@ -34,13 +37,16 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
pullRetries (int) — ретраи pull
|
||||
pullRetryDelay (int) — пауза между ретраями (сек)
|
||||
pullTimeout (int) — таймаут одного GET pull
|
||||
onFileReceived (func) — опциональный callback (sid, name, content) для Слоя 3
|
||||
"""
|
||||
cfg = cfg or {}
|
||||
prefix = cfg.get("apiPrefix", "/api")
|
||||
vm_prefix = cfg.get("vmUploadPrefix", VM_UPLOAD_PREFIX)
|
||||
max_file_bytes = cfg.get("maxFileBytes", MAX_FILE_BYTES)
|
||||
pull_retries = cfg.get("pullRetries", PULL_RETRIES)
|
||||
pull_delay = cfg.get("pullRetryDelay", PULL_RETRY_DELAY)
|
||||
pull_timeout = cfg.get("pullTimeout", 120)
|
||||
on_file_received = cfg.get("onFileReceived")
|
||||
|
||||
# Применить лимиты сессии/TTL из конфига (глобально для всех сессий)
|
||||
configure(
|
||||
@@ -49,6 +55,8 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
ttl_seconds=cfg.get("ttlSeconds"),
|
||||
)
|
||||
|
||||
is_path_only_prefix = vm_prefix.startswith("/")
|
||||
|
||||
bp = Blueprint("upload_refs", __name__, url_prefix=prefix)
|
||||
|
||||
@bp.route("/upload_refs", methods=["POST"])
|
||||
@@ -56,9 +64,9 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
|
||||
|
||||
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
|
||||
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
|
||||
Каждый файл тянется исходящим GET с ВМ (egress не ограничен шлюзом),
|
||||
читается по частям (stream), кладётся в сессию. После успешного pull файл
|
||||
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
|
||||
удаляется с ВМ (DELETE).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
sid = data.get("session") or create_session()
|
||||
@@ -67,18 +75,26 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
log.warning("upload_refs: no files, sid=%s", sid)
|
||||
return jsonify({"ok": False, "error": "No files"}), 400
|
||||
added = 0
|
||||
transport = cfg.get("httpxTransport") or current_app.config.get("UPLOAD_HTTPX_TRANSPORT")
|
||||
try:
|
||||
with httpx.Client(timeout=pull_timeout, follow_redirects=True) as client:
|
||||
with httpx.Client(transport=transport, timeout=pull_timeout, follow_redirects=True) as client:
|
||||
for ref in refs:
|
||||
name = safe_name(ref.get("name") or "")
|
||||
url = ref.get("url")
|
||||
url = ref.get("url") or ""
|
||||
if not name or not url:
|
||||
continue
|
||||
# SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера
|
||||
if not url.startswith(vm_prefix):
|
||||
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
|
||||
continue
|
||||
# Лимит на один файл: сверх лимита — пропускаем (не участвует)
|
||||
if is_path_only_prefix:
|
||||
if not urlsplit(url).path.startswith(vm_prefix):
|
||||
log.warning("upload_refs: unsafe URL path, skip sid=%s url=%r", sid, url)
|
||||
continue
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = request.host_url.rstrip("/") + ("/" if not url.startswith("/") else "") + url
|
||||
else:
|
||||
if not url.startswith(vm_prefix):
|
||||
log.warning("upload_refs: unsafe URL, skip sid=%s url=%r", sid, url)
|
||||
continue
|
||||
# Лимит на один файл: сверх лимита — пропускаем
|
||||
if (ref.get("size") or 0) > max_file_bytes:
|
||||
log.warning("upload_refs: file exceeds %dMB, skip sid=%s file=%r size=%s",
|
||||
max_file_bytes // (1024 * 1024), sid, name, ref.get("size"))
|
||||
@@ -87,8 +103,8 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Pull с ретраями: разовые DNS/сетевые сбои не роняют всю загрузку
|
||||
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name) # бросает при неудаче всех попыток
|
||||
# Pull с ретраями
|
||||
content = pull_file(client, url, pull_retries, pull_delay, sid=sid, name=name)
|
||||
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
|
||||
if len(content) > max_file_bytes:
|
||||
log.warning("upload_refs: pulled file exceeds %dMB, skip sid=%s file=%r size=%d",
|
||||
@@ -99,7 +115,6 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
pass
|
||||
continue
|
||||
if not add_file(sid, name, content):
|
||||
# Различить: сессия исчезла vs превышен суммарный лимит сессии
|
||||
if get_files(sid) is None:
|
||||
log.warning("upload_refs: session not found, sid=%s file=%r", sid, name)
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
@@ -109,15 +124,22 @@ def create_upload_refs_blueprint(cfg: dict) -> Blueprint:
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Успешно добавлен в сессию — удаляем с ВМ-буфера
|
||||
try:
|
||||
client.delete(url) # убрать файл с ВМ после загрузки
|
||||
client.delete(url)
|
||||
except Exception:
|
||||
pass
|
||||
# Если передан callback для Слоя 3 (эмуляция или реальный процессинг)
|
||||
if callable(on_file_received):
|
||||
try:
|
||||
on_file_received(sid, name, content)
|
||||
except Exception as cb_err:
|
||||
log.warning("upload_refs: on_file_received callback error: %r", cb_err)
|
||||
added += 1
|
||||
except Exception as e:
|
||||
log.error("upload_refs: pull error sid=%s: %r", sid, e)
|
||||
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
|
||||
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
|
||||
return jsonify({"ok": True, "session": sid, "count": file_count(sid), "added": added})
|
||||
|
||||
return bp
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
PULL_RETRIES = 3
|
||||
PULL_RETRY_DELAY = 2 # секунды между попытками
|
||||
|
||||
# Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF)
|
||||
# Доверенный префикс ВМ-буфера по умолчанию — валидация URL при pull (защита от SSRF)
|
||||
VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/"
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
{
|
||||
"vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/",
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
|
||||
"maxFileBytes": 52428800,
|
||||
"maxSessionBytes": 524288000,
|
||||
"apiPrefix": "/api",
|
||||
"vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/",
|
||||
"pullRetries": 3,
|
||||
"pullRetryDelay": 2,
|
||||
"pullTimeout": 120,
|
||||
"ttlSeconds": 1800,
|
||||
"estMbSec": 12
|
||||
}
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { addFiles, onFilesChange } from './table/on_files_change.js';
|
||||
import { onFolderChange } from './table/on_folder_change.js';
|
||||
import { findNode, flattenFiles, render } from './table/render.js';
|
||||
import { esc } from './table/esc.js';
|
||||
|
||||
const DEFAULTS = {
|
||||
allowedExt: [],
|
||||
labels: {
|
||||
pickFiles: 'Выбрать файлы',
|
||||
pickFolder: 'Выбрать папку',
|
||||
clear: 'Очистить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${bytes} B`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
|
||||
limits: {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveMount(mount) {
|
||||
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
|
||||
if (!element) throw new Error('File picker mount element не найден');
|
||||
return element;
|
||||
}
|
||||
|
||||
function normalizeConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
|
||||
labels: {
|
||||
...DEFAULTS.labels,
|
||||
...(config.labels || {}),
|
||||
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
|
||||
},
|
||||
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
|
||||
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.querySelector('style[data-file-picker]')) return;
|
||||
const style = document.createElement('style');
|
||||
style.dataset.filePicker = '';
|
||||
style.textContent = `
|
||||
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
|
||||
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
||||
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
|
||||
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
|
||||
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
|
||||
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
||||
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
|
||||
.file-picker .fp-count { min-height: 22px; }
|
||||
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
|
||||
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
|
||||
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
||||
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
||||
.file-picker .tree-row td:first-child { white-space: nowrap; }
|
||||
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function buildMarkup(root, cfg) {
|
||||
const controls = cfg.layout.controls || [];
|
||||
const control = (name, id, label, className = '') => controls.includes(name)
|
||||
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
|
||||
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
|
||||
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
|
||||
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
|
||||
</div>
|
||||
<p class="fp-status" role="status"></p>
|
||||
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
|
||||
<p class="fp-count"></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function notify(cfg, state) {
|
||||
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
|
||||
}
|
||||
|
||||
function removeMetadata(state, node) {
|
||||
if (node.kind === 'file') {
|
||||
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
||||
return;
|
||||
}
|
||||
node.children.forEach((child) => removeMetadata(state, child));
|
||||
}
|
||||
|
||||
function removeNode(state, id) {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (!found) return false;
|
||||
removeMetadata(state, found.node);
|
||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initFilePicker(config) {
|
||||
const cfg = normalizeConfig(config);
|
||||
const mount = resolveMount(cfg.mount);
|
||||
injectStyles();
|
||||
buildMarkup(mount, cfg);
|
||||
const root = mount.firstElementChild;
|
||||
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'),
|
||||
};
|
||||
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
||||
const listeners = [];
|
||||
const listen = (target, event, handler) => {
|
||||
target.addEventListener(event, handler);
|
||||
listeners.push(() => target.removeEventListener(event, handler));
|
||||
};
|
||||
const redraw = () => render(state, elements, cfg);
|
||||
const filesChange = onFilesChange(state, cfg, elements);
|
||||
const folderChange = onFolderChange(state, cfg, elements);
|
||||
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
|
||||
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
|
||||
listen(elements.tableBodyEl, '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;
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
if (removeNode(state, remove.dataset.remove)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
|
||||
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
|
||||
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
redraw();
|
||||
return {
|
||||
pickFiles: () => elements.fileInputEl.click(),
|
||||
pickFolder: () => elements.folderInputEl.click(),
|
||||
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
|
||||
getFiles: () => [...flattenFiles(state.nodes)],
|
||||
remove: (id) => {
|
||||
if (removeNode(state, id)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
render: redraw,
|
||||
destroy: () => {
|
||||
listeners.forEach((removeListener) => removeListener());
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
mount.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { putToVm } from './upload/put_to_vm.js';
|
||||
export { uploadViaVM } from './upload/upload_via_vm.js';
|
||||
export { DEFAULTS };
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "upload-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip."
|
||||
}
|
||||
@@ -1,47 +1,54 @@
|
||||
// addFileWithDedup — дедуп «имя+размер», суффиксы _2/_3, лимиты (over).
|
||||
// Мутирует state. Возвращает true если файл добавлен (или переведён в over),
|
||||
// false если это дедуп (обновлена только дата).
|
||||
|
||||
export function addFileWithDedup(state, file, cfg) {
|
||||
const size = file.size;
|
||||
const mtime = file.lastModified;
|
||||
let name = file.name;
|
||||
const maxFileBytes = cfg.maxFileBytes;
|
||||
const maxSessionBytes = cfg.maxSessionBytes;
|
||||
|
||||
if (state.fileMeta.has(name)) {
|
||||
const e = state.fileMeta.get(name);
|
||||
if (e.size === size) {
|
||||
// Тот же файл (имя+размер) — дедуп. Дату не сравниваем: файл могли пересохранить
|
||||
// с тем же содержимым. Оставляем более свежий по дате.
|
||||
if (mtime > e.mtime) {
|
||||
e.mtime = mtime;
|
||||
const idx = state.files.findIndex(f => f.name === name);
|
||||
if (idx >= 0) state.files[idx] = new File([file], name, { lastModified: mtime });
|
||||
}
|
||||
return false; // дедуп: файл не добавлен (обновлена только дата)
|
||||
/**
|
||||
* Добавляет узел файла или группы в состояние picker с рекурсивной дедупликацией.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>}} state Внутреннее состояние таблицы.
|
||||
* @param {{kind: string, path: string, file?: File, children?: Array}} fileNode
|
||||
* Корневой узел файла, папки или ZIP-архива.
|
||||
* @returns {boolean} True, если в состояние добавлен хотя бы один узел.
|
||||
*
|
||||
* Ключ дедупликации состоит из полного пути и размера файла. Это позволяет
|
||||
* повторно выбрать файл после удаления и одновременно не скрывает файл с тем
|
||||
* же путём, но другим размером.
|
||||
*/
|
||||
export function addFileWithDedup(state, fileNode) {
|
||||
// Удаляет дубли и пустые группы снизу вверх, сохраняя исходные File objects.
|
||||
const accept = (node) => {
|
||||
if (node.kind === 'file') {
|
||||
// NUL-разделитель исключает неоднозначность при склейке пути и размера.
|
||||
const key = `${node.path}\u0000${node.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node;
|
||||
}
|
||||
// Группа нужна только пока после фильтрации в ней остался хотя бы один leaf.
|
||||
node.children = node.children.map(accept).filter(Boolean);
|
||||
return node.children.length ? node : null;
|
||||
};
|
||||
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;
|
||||
}
|
||||
// Имя то же, размер другой — добавить с суффиксом _2, _3...
|
||||
const dot = name.lastIndexOf('.');
|
||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
let n = 2;
|
||||
while (state.fileMeta.has(base + '_' + n + ext)) n++;
|
||||
name = base + '_' + n + ext;
|
||||
}
|
||||
state.fileMeta.set(name, { size, mtime });
|
||||
|
||||
// Определяем, превышает ли файл лимит (по размеру файла или суммарный) — не участвует в обфускации
|
||||
let over = false;
|
||||
if (size > maxFileBytes) over = true; // лимит на один файл
|
||||
const sum = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||
if (sum + size > maxSessionBytes) over = true; // суммарный лимит сессии
|
||||
|
||||
if (over) {
|
||||
state.overNames.add(name);
|
||||
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||
return true;
|
||||
}
|
||||
state.files.push(new File([file], name, { lastModified: mtime }));
|
||||
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,5 +1,18 @@
|
||||
// esc — экранирование HTML (защита от self-XSS именами файлов).
|
||||
|
||||
export function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
/**
|
||||
* Экранирует текст перед вставкой в HTML-шаблон, собираемый через innerHTML.
|
||||
*
|
||||
* @param {*} value Имя файла, путь или сообщение статуса.
|
||||
* @returns {string} Строка с заменёнными HTML-значимыми символами.
|
||||
*
|
||||
* Экранируются и кавычки, потому что значение может попасть не только в текст
|
||||
* кнопки, но и в HTML-атрибут вроде data-path или aria-label.
|
||||
*/
|
||||
export function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[character]));
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// estForFile — эмпирическая оценка времени обработки файла: сек/МБ (ориентировочно, до старта).
|
||||
|
||||
export const DEFAULT_EST_MB_SEC = 12;
|
||||
|
||||
export function estForFile(f, estMbSec) {
|
||||
const k = estMbSec || DEFAULT_EST_MB_SEC;
|
||||
return f ? Math.max(1, Math.round(f.size / 1048576 * k)) : 0;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// fmtSec — формат секунд: "Nс" / "Nм Nс".
|
||||
|
||||
export function fmtSec(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// fs — формат размера: B / KB / MB.
|
||||
|
||||
export function fs(b) {
|
||||
return b < 1024 ? b + ' B'
|
||||
: b < 1048576 ? (b / 1024).toFixed(1) + ' KB'
|
||||
: (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
/**
|
||||
* Преобразует размер файла из байтов в короткую строку для таблицы.
|
||||
*
|
||||
* @param {number} bytes Размер в байтах.
|
||||
* @returns {string} Значение в B, KB или MB с одним знаком после запятой.
|
||||
*/
|
||||
export function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B`
|
||||
: bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||
: `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// initUploadTable — сборка слоя 1 (выбор файлов/папки/архива).
|
||||
// Состояние (files/fileMeta/overNames) живёт внутри модуля.
|
||||
|
||||
import { addFiles as addFilesToState, makeFilesChangeHandler } from './on_files_change.js';
|
||||
import { makeFolderChangeHandler } from './on_folder_change.js';
|
||||
import { render } from './render.js';
|
||||
import { setStatus } from './set_status.js';
|
||||
import { rmFile } from './rm_file.js';
|
||||
|
||||
// cfg: {allowedExt, maxFileBytes, maxSessionBytes, estMbSec}
|
||||
// els: {fileInput, folderInput, tableBody, countEl, uploadBtnEl, onStatus(cls, text)}
|
||||
// returns api (см. README.md)
|
||||
export function initUploadTable(cfg, els) {
|
||||
const state = {
|
||||
files: [], // File[]
|
||||
fileMeta: new Map(), // имя -> {size, mtime} для дедупа/суффиксов
|
||||
overNames: new Set(), // имена файлов сверх лимита (не участвуют)
|
||||
busy: false, // идёт загрузка/обработка — список заблокирован
|
||||
proc: null, // {phase, procState, procExtractRate} — задаёт слой 3
|
||||
};
|
||||
|
||||
const onFilesChange = makeFilesChangeHandler({ state, cfg, els, onStatus: els.onStatus });
|
||||
const onFolderChange = makeFolderChangeHandler({ state, cfg, els, onStatus: els.onStatus });
|
||||
|
||||
els.fileInput.addEventListener('change', onFilesChange);
|
||||
els.folderInput.addEventListener('change', onFolderChange);
|
||||
// Делегирование кликов по кнопкам «✕» (удалить строку)
|
||||
els.tableBody.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.remove-btn');
|
||||
if (btn) rmFile(state, els, cfg, Number(btn.dataset.idx));
|
||||
});
|
||||
|
||||
return {
|
||||
state, // доступ для слоя 3 (установить state.proc для render)
|
||||
addFiles(files) { return addFilesToState(state, cfg, files, els, els.onStatus); },
|
||||
getFiles() { // ТОЛЬКО учитываемые (без over): [{name, size, file}]
|
||||
return state.files.filter(f => !state.overNames.has(f.name))
|
||||
.map(f => ({ name: f.name, size: f.size, file: f }));
|
||||
},
|
||||
getOverNames() { return state.overNames; },
|
||||
setStatus(idx, html) { setStatus(idx, html); },
|
||||
render() { render(state, els, cfg); },
|
||||
clear() {
|
||||
state.files = [];
|
||||
state.fileMeta = new Map();
|
||||
state.overNames = new Set();
|
||||
if (els.fileInput) els.fileInput.value = '';
|
||||
render(state, els, cfg);
|
||||
},
|
||||
setBusy(b) {
|
||||
state.busy = b;
|
||||
if (els.fileInput) els.fileInput.disabled = b;
|
||||
},
|
||||
_rm(i) { rmFile(state, els, cfg, i); },
|
||||
};
|
||||
}
|
||||
@@ -1,56 +1,72 @@
|
||||
// Обработчик <input type="file" multiple> change: дедуп + раскрытие ZIP + фильтр.
|
||||
// Экспортируется и чистая логика добавления массива файлов (addFiles),
|
||||
// и фабрика обработчика для input (makeFilesChangeHandler).
|
||||
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
// Добавить массив файлов в список (дедуп + раскрытие ZIP + лимиты).
|
||||
// files: File[]. els.fileInput — для синхронизации input.files (DataTransfer),
|
||||
// чтобы повторный выбор того же файла сработал. onStatus(cls, text) — колбэк сообщений.
|
||||
export async function addFiles(state, cfg, files, els, onStatus) {
|
||||
const incoming = Array.from(files);
|
||||
const hasZip = incoming.some(f => f.name.toLowerCase().endsWith('.zip'));
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = 'wait';
|
||||
if (onStatus) onStatus('progress', 'Разбираю архивы…');
|
||||
}
|
||||
try {
|
||||
for (const f of incoming) {
|
||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||
try {
|
||||
const nested = await listZipFiles(f, cfg.allowedExt);
|
||||
if (nested.length) nested.forEach(x => addFileWithDedup(state, x, cfg));
|
||||
else addFileWithDedup(state, f, cfg); // в архиве нет документов — архив как есть
|
||||
} catch (err) {
|
||||
addFileWithDedup(state, f, cfg); // не удалось распаковать — zip как есть
|
||||
}
|
||||
} else {
|
||||
addFileWithDedup(state, f, cfg);
|
||||
/**
|
||||
* Обрабатывает список файлов из обычного file input.
|
||||
*
|
||||
* @param {{fileKeys: Set<string>, nodes: Array, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Конфигурация разрешённых расширений.
|
||||
* @param {FileList|File[]} files Выбранные browser File objects.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы вывода.
|
||||
* @returns {Promise<void>} Завершается после разбора всех ZIP и перерисовки таблицы.
|
||||
*
|
||||
* Обычные документы добавляются сразу. ZIP читаются асинхронно в браузере;
|
||||
* ошибка одного архива не отменяет обработку остальных выбранных файлов.
|
||||
*/
|
||||
export async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Фильтр повторяет accept в HTML, потому что accept не является защитой API.
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
// Обычный файл получает имя как путь: у file input нет относительного пути.
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(), kind: 'file', name: file.name, path: file.name,
|
||||
file, children: [], expanded: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = '';
|
||||
if (onStatus) onStatus('', '');
|
||||
try {
|
||||
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') {
|
||||
cfg.onError(error, file);
|
||||
} else if (elements.statusEl) {
|
||||
elements.statusEl.textContent = `Ошибка чтения архива ${file.name}: ${error.message}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Синхронизировать input.files — чтобы повторный выбор того же файла сработал
|
||||
if (els && els.fileInput && els.fileInput.files) {
|
||||
const d = new DataTransfer();
|
||||
state.files.forEach(f => d.items.add(f));
|
||||
els.fileInput.files = d.files;
|
||||
}
|
||||
render(state, els, cfg);
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
// Фабрика обработчика change для <input type="file">.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFilesChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
return () => {
|
||||
if (state.busy) return; // во время загрузки/обработки менять список нельзя
|
||||
addFiles(state, cfg, els.fileInput.files, els, onStatus);
|
||||
/**
|
||||
* Создаёт обработчик change для обычного выбора файлов.
|
||||
*
|
||||
* @param {object} state Состояние picker, включая флаг busy.
|
||||
* @param {object} cfg Конфигурация picker.
|
||||
* @param {object} elements DOM-элементы picker.
|
||||
* @returns {Function} Async-обработчик для addEventListener('change', ...).
|
||||
*
|
||||
* busy устанавливается синхронно до первого await. Поэтому второе быстрое
|
||||
* событие выбора не запускает параллельный разбор и не меняет дерево конкурирующе.
|
||||
*/
|
||||
export function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
// Даже исключение вне внутреннего ZIP-catch не оставляет picker заблокированным.
|
||||
elements.fileInputEl.value = '';
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,84 @@
|
||||
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
|
||||
// относительный путь сохраняется (верхняя папка отбрасывается).
|
||||
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { rebaseTree } from './rebase_tree.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
// Фабрика обработчика change для input выбора папки.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFolderChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
/**
|
||||
* Создаёт async-обработчик выбора каталога через webkitdirectory.
|
||||
*
|
||||
* @param {{nodes: Array, fileKeys: Set<string>, busy: boolean}} state Состояние picker.
|
||||
* @param {{allowedExt: string[]}} cfg Разрешённые расширения.
|
||||
* @param {{folderInputEl: HTMLInputElement, tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-элементы.
|
||||
* @returns {Function} Async-обработчик события change.
|
||||
*
|
||||
* Браузер отдаёт плоский FileList с webkitRelativePath. Обработчик группирует
|
||||
* его по первой части пути, создаёт промежуточные папки и затем добавляет каждый
|
||||
* корень через общую дедупликацию. ZIP внутри каталога раскрывается тем же кодом,
|
||||
* что и ZIP из обычного file input.
|
||||
*/
|
||||
export function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return; // во время загрузки/обработки менять список нельзя
|
||||
const incoming = Array.from(els.folderInput.files);
|
||||
if (!incoming.length) return;
|
||||
document.body.style.cursor = 'wait';
|
||||
if (onStatus) onStatus('progress', 'Разбираю папку…');
|
||||
let added = 0;
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
for (const f of incoming) {
|
||||
// webkitRelativePath: "TopFolder/Подпапка/file.pdf" — отбрасываем верхнюю папку
|
||||
const parts = (f.webkitRelativePath || f.name).split('/');
|
||||
const rel = parts.slice(1).join('/') || f.name;
|
||||
const low = rel.toLowerCase();
|
||||
const slashIdx = rel.lastIndexOf('/');
|
||||
const relDir = slashIdx >= 0 ? rel.slice(0, slashIdx) : '';
|
||||
if (low.endsWith('.zip')) {
|
||||
try {
|
||||
const nested = await listZipFiles(f, cfg.allowedExt);
|
||||
if (nested.length) {
|
||||
for (const nf of nested) {
|
||||
const nm = relDir ? relDir + '/' + nf.name : nf.name;
|
||||
if (addFileWithDedup(state, new File([nf], nm, { lastModified: nf.lastModified }), cfg)) added++;
|
||||
}
|
||||
} else {
|
||||
// в архиве нет документов — добавить архив как есть, чтобы не терялся
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
|
||||
}
|
||||
} catch (err) {
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++; // zip как есть
|
||||
}
|
||||
} else if (cfg.allowedExt.some(e => low.endsWith(e))) {
|
||||
if (addFileWithDedup(state, new File([f], rel, { lastModified: f.lastModified }), cfg)) added++;
|
||||
const roots = new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
// Для webkitdirectory путь начинается с выбранной корневой папки.
|
||||
const parts = (file.webkitRelativePath || file.name).split('/');
|
||||
const relativePath = parts.slice(1).join('/') || file.name;
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
// Один root на выбранный каталог позволяет сохранить дерево целиком.
|
||||
roots.set(rootName, { id: crypto.randomUUID(), kind: 'folder', name: rootName,
|
||||
path: rootName, children: [], expanded: false });
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
// Вставляет узел по его пути, создавая отсутствующие промежуточные папки.
|
||||
const addToFolder = (node) => {
|
||||
let current = root;
|
||||
const nodeParts = node.path.split('/').slice(1);
|
||||
nodeParts.forEach((part, index) => {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node : { id: crypto.randomUUID(), kind: 'folder', name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join('/')}`,
|
||||
children: [], expanded: false };
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
if (lowerPath.endsWith('.zip')) {
|
||||
try {
|
||||
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
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))) {
|
||||
addToFolder({ id: crypto.randomUUID(), kind: 'file', name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`, file, children: [], expanded: true });
|
||||
}
|
||||
// иначе — не документ, пропускаем
|
||||
}
|
||||
// Дедупликация выполняется после сборки каждого корня каталога.
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
els.folderInput.value = ''; // чтобы повторный выбор той же папки сработал
|
||||
render(state, els, cfg);
|
||||
if (added && onStatus) {
|
||||
const n = added;
|
||||
const w = (n % 10 === 1 && n % 100 !== 11) ? 'файл' : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 'файла' : 'файлов';
|
||||
onStatus('done', '✅ Добавлено из папки: ' + n + ' ' + w);
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// procRow — строка таблицы обработки (3-секционная).
|
||||
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
export function procRow(state, i, stTxt) {
|
||||
const f = state.files[i];
|
||||
const over = state.overNames.has(f.name);
|
||||
const p = state.proc && state.proc.procState ? state.proc.procState[i] : null;
|
||||
const cls = (p && p.st === 'current') ? ' class="row-current"'
|
||||
: (over ? ' class="row-over"' : '');
|
||||
return '<tr' + cls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" style="font-size:12px;">' + stTxt + '</td><td></td></tr>';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Добавляет префикс корневой папки к каждому пути дерева.
|
||||
*
|
||||
* @param {{path: string, file?: File, children: Array}} root Узел дерева.
|
||||
* @param {string} prefix Путь выбранной папки, в которую попал узел.
|
||||
* @returns {object} Тот же узел после изменения путей.
|
||||
*
|
||||
* Для leaf-файлов создаётся новый File с тем же базовым именем; логический путь
|
||||
* хранится отдельно в узле дерева.
|
||||
*/
|
||||
export function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
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;
|
||||
}
|
||||
+120
-25
@@ -1,29 +1,124 @@
|
||||
// render — рендер таблицы выбора файлов (обычная).
|
||||
// Если идёт обработка (state.proc.phase === 'processing') — 3-секционная.
|
||||
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
import { fmtSec } from './fmt_sec.js';
|
||||
import { estForFile } from './est_for_file.js';
|
||||
import { renderProcTable } from './render_proc_table.js';
|
||||
|
||||
export function render(state, els, cfg) {
|
||||
if (state.proc && state.proc.phase === 'processing') { renderProcTable(state, els, cfg); return; }
|
||||
if (state.files.length === 0) {
|
||||
els.tableBody.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||
} else {
|
||||
els.tableBody.innerHTML = state.files.map((f, i) => {
|
||||
const over = state.overNames.has(f.name);
|
||||
const rowCls = over ? ' class="row-over"' : '';
|
||||
const stTxt = over ? '<span style="color:#c0392b;">🔥 не учитывается</span>'
|
||||
: '<span style="color:#7d3c98;">~' + fmtSec(estForFile(f, cfg.estMbSec)) + '</span>';
|
||||
return '<tr id="row-' + i + '"' + rowCls + '><td class="name-cell">' + esc(f.name) + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">' + stTxt + '</td><td><button class="remove-btn" data-idx="' + i + '">✕</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
const overCount = state.files.filter(f => state.overNames.has(f.name)).length;
|
||||
const totalSize = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : f.size), 0);
|
||||
const cntMain = state.files.length - overCount;
|
||||
const totEst = state.files.reduce((s, f) => s + (state.overNames.has(f.name) ? 0 : estForFile(f, cfg.estMbSec)), 0);
|
||||
els.countEl.textContent = (overCount ? cntMain + ' учитываются + ' + overCount + ' свыше лимита' : state.files.length) + ' файлов · ' + fs(totalSize) + ' · ~' + fmtSec(totEst);
|
||||
els.uploadBtnEl.disabled = cntMain === 0;
|
||||
const DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'] },
|
||||
};
|
||||
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...(cfg.labels || {}),
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...(cfg.labels?.columns || {}),
|
||||
},
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
|
||||
*
|
||||
* @param {{kind: string, id: string, name: string, path: string, file?: File,
|
||||
* children: Array, expanded: boolean, error?: string}} node Узел file/folder/zip.
|
||||
* @param {number} depth Глубина узла; используется для визуального отступа.
|
||||
* @returns {string} HTML одной строки или поддерева строк.
|
||||
*
|
||||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||||
*/
|
||||
function renderNode(node, depth, cfg) {
|
||||
const isGroup = node.kind !== 'file';
|
||||
const padding = depth * 4;
|
||||
const name = esc(node.name);
|
||||
const action = isGroup
|
||||
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
|
||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
|
||||
};
|
||||
const columns = cfg.layout.columns?.length
|
||||
? cfg.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
// Дочерние строки добавляются только для раскрытой группы.
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью синхронизирует DOM таблицы и счётчик с текущим state.nodes.
|
||||
*
|
||||
* @param {{nodes: Array}} state Состояние дерева.
|
||||
* @param {{tableBodyEl: HTMLElement, countEl: HTMLElement}} elements DOM-вывод.
|
||||
* @returns {void}
|
||||
*
|
||||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||
* в количество leaf-файлов и суммарный размер.
|
||||
*/
|
||||
export function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length
|
||||
? normalized.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
|
||||
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||||
const visit = (node) => {
|
||||
if (node.kind === 'file') files.push(node);
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет узел по уникальному id и возвращает также массив его непосредственного родителя.
|
||||
*
|
||||
* @param {Array} nodes Массив узлов текущего уровня.
|
||||
* @param {string} id Идентификатор, созданный через crypto.randomUUID().
|
||||
* @returns {{node: object, nodes: Array}|null} Узел и контейнер для удаления либо null.
|
||||
*/
|
||||
export function findNode(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return { node, nodes };
|
||||
const found = findNode(node.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует иерархическое дерево в плоский список leaf-файлов.
|
||||
*
|
||||
* @param {Array} nodes Узлы любого уровня.
|
||||
* @param {Array} result Внутренний аккумулятор рекурсивного вызова.
|
||||
* @returns {Array<{path: string, name: string, size: number, file: File}>} Файлы для приложения.
|
||||
*/
|
||||
export function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.kind === 'file') result.push({ path: node.path, name: node.name, size: node.file.size, file: node.file });
|
||||
else flattenFiles(node.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// renderProcTable — 3-секционная таблица во время обработки
|
||||
// (готово / текущий / ожидают / пропущены сверх лимита).
|
||||
|
||||
import { fmtSec } from './fmt_sec.js';
|
||||
import { estForFile, DEFAULT_EST_MB_SEC } from './est_for_file.js';
|
||||
import { procRow } from './proc_row.js';
|
||||
|
||||
export function renderProcTable(state, els, cfg) {
|
||||
const procState = state.proc.procState;
|
||||
const groups = { done: [], current: [], pending: [], over: [] };
|
||||
for (let i = 0; i < state.files.length; i++) {
|
||||
if (state.overNames.has(state.files[i].name)) { groups.over.push(i); continue; }
|
||||
const st = procState[i] ? procState[i].st : 'pending';
|
||||
if (st === 'done' || st === 'skipped') groups.done.push(i);
|
||||
else if (st === 'current') groups.current.push(i);
|
||||
else groups.pending.push(i);
|
||||
}
|
||||
// Оценка скорости из текущего файла (сек/символ) — для «ожидающих»
|
||||
let rate = null;
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = (performance.now() - p.t0) / 1000;
|
||||
const total = cur + (p.eta != null ? p.eta : 0);
|
||||
if (p.chars > 0 && total > 0) rate = total / p.chars;
|
||||
}
|
||||
const rows = [];
|
||||
if (groups.done.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">✓ Обработанные (' + groups.done.length + ')</td></tr>');
|
||||
for (const i of groups.done) {
|
||||
const p = procState[i];
|
||||
const txt = p.st === 'skipped'
|
||||
? '<span style="color:#c0392b;" title="Не удалось прочитать файл: пустой, повреждённый или скан без текста">не извлечён</span>'
|
||||
: '<span style="color:#22c55e;">✓ ' + (p.elapsed ? p.elapsed.toFixed(1) : '0.0') + 'с</span>';
|
||||
rows.push(procRow(state, i, txt));
|
||||
}
|
||||
}
|
||||
if (groups.over.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">⛔ Пропущены (сверх лимита) (' + groups.over.length + ')</td></tr>');
|
||||
for (const i of groups.over) {
|
||||
rows.push(procRow(state, i, '<span style="color:#c0392b;">пропущен (лимит)</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.current.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">▶ Текущий файл</td></tr>');
|
||||
for (const i of groups.current) {
|
||||
const p = procState[i];
|
||||
const cur = ((performance.now() - p.t0) / 1000).toFixed(1);
|
||||
const eta = (p.eta != null) ? ' / ~' + fmtSec(p.eta) : '';
|
||||
rows.push(procRow(state, i, '<span style="color:#2563eb;">⏳ ' + cur + 'с' + eta + '</span>'));
|
||||
}
|
||||
}
|
||||
if (groups.pending.length) {
|
||||
rows.push('<tr class="grp-row"><td colspan="4">○ Ожидают обработки (' + groups.pending.length + ')</td></tr>');
|
||||
for (const i of groups.pending) {
|
||||
const p = procState[i] || {};
|
||||
// Оценка: LLM (chars x rate) если известна; иначе грубая по размеру/скорости извлечения
|
||||
if (rate && !p.est && p.chars > 0) p.est = p.chars * rate;
|
||||
if (!p.est) {
|
||||
const szMB = (state.files[i] ? state.files[i].size : 0) / 1048576;
|
||||
const k = (state.proc && state.proc.procExtractRate) || cfg.estMbSec || DEFAULT_EST_MB_SEC; // сек/МБ
|
||||
p.est = Math.max(1, Math.round(szMB * k));
|
||||
}
|
||||
let txt;
|
||||
if (p.st === 'analyzed') txt = '<span style="color:#7d3c98;">анализ ✓</span>';
|
||||
else if (p.st === 'current') txt = '<span style="color:#2563eb;">⏳</span>';
|
||||
else if (p.est != null) txt = '<span style="color:#999;">~' + fmtSec(p.est) + '</span>';
|
||||
else txt = '<span style="color:#999;">—</span>';
|
||||
rows.push(procRow(state, i, txt));
|
||||
}
|
||||
}
|
||||
els.tableBody.innerHTML = rows.join('');
|
||||
const cntDone = groups.done.length;
|
||||
els.countEl.textContent = 'Готово ' + cntDone + ' / ' + state.files.length + ' файлов';
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// rmFile — удалить файл из списка (если не busy).
|
||||
|
||||
import { render } from './render.js';
|
||||
|
||||
export function rmFile(state, els, cfg, i) {
|
||||
if (state.busy) return;
|
||||
const nm = state.files[i].name;
|
||||
state.overNames.delete(nm);
|
||||
state.fileMeta.delete(nm);
|
||||
state.files.splice(i, 1);
|
||||
// Синхронизировать input.files (DataTransfer) — чтобы повторный выбор того же
|
||||
// файла сработал (change не сработает, если files не обновлён).
|
||||
if (els.fileInput && els.fileInput.files) {
|
||||
const d = new DataTransfer();
|
||||
state.files.forEach(f => d.items.add(f));
|
||||
els.fileInput.files = d.files;
|
||||
}
|
||||
render(state, els, cfg);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
|
||||
|
||||
export function setStatus(idx, html) {
|
||||
const e = document.getElementById('st-' + idx);
|
||||
if (e) e.innerHTML = html;
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
// Юнит-тесты фронта модуля upload: zip (кириллица, вложенный zip, не-doc) + дедуп/лимиты.
|
||||
// Запуск: node upload/frontend/test_upload_frontend.mjs
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
|
||||
// ── Полифилл File (Node 18 не имеет global File) ──
|
||||
if (typeof globalThis.File === 'undefined') {
|
||||
globalThis.File = class File extends Blob {
|
||||
constructor(parts, name, opts) {
|
||||
super(parts, opts);
|
||||
this.name = name;
|
||||
this.lastModified = (opts && opts.lastModified) || Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
import { listZipFiles } from './zip/list_zip_files.js';
|
||||
import { parseZip } from './zip/parse_zip.js';
|
||||
import { decodeZipName } from './zip/decode_zip_name.js';
|
||||
import { addFileWithDedup } from './table/add_file_with_dedup.js';
|
||||
import { esc } from './table/esc.js';
|
||||
import { fs } from './table/fs.js';
|
||||
import { fmtSec } from './table/fmt_sec.js';
|
||||
import { estForFile } from './table/est_for_file.js';
|
||||
|
||||
const ALLOWED = ['.pdf', '.doc', '.docx', '.txt', '.md'];
|
||||
|
||||
// ── CRC32 (для сборки тестовых ZIP) ──
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function crc32(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// ── Сборка минимального ZIP (method 0, UTF-8-флаг) ──
|
||||
function makeZip(entries) {
|
||||
const enc = new TextEncoder();
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
const utf8Flag = 0x0800;
|
||||
for (const e of entries) {
|
||||
const nameB = enc.encode(e.name);
|
||||
const dataB = typeof e.data === 'string' ? enc.encode(e.data) : e.data;
|
||||
const crc = crc32(dataB);
|
||||
|
||||
const lh = new DataView(new ArrayBuffer(30));
|
||||
lh.setUint32(0, 0x04034b50, true);
|
||||
lh.setUint16(4, 20, true);
|
||||
lh.setUint16(6, utf8Flag, true);
|
||||
lh.setUint16(8, 0, true);
|
||||
lh.setUint16(10, 0, true);
|
||||
lh.setUint16(12, 0x21, true);
|
||||
lh.setUint32(14, crc, true);
|
||||
lh.setUint32(18, dataB.length, true);
|
||||
lh.setUint32(22, dataB.length, true);
|
||||
lh.setUint16(26, nameB.length, true);
|
||||
lh.setUint16(28, 0, true);
|
||||
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(dataB));
|
||||
|
||||
const cd = new DataView(new ArrayBuffer(46));
|
||||
cd.setUint32(0, 0x02014b50, true);
|
||||
cd.setUint16(4, 20, true);
|
||||
cd.setUint16(6, 20, true);
|
||||
cd.setUint16(8, utf8Flag, true);
|
||||
cd.setUint16(10, 0, true);
|
||||
cd.setUint16(12, 0, true);
|
||||
cd.setUint16(14, 0x21, true);
|
||||
cd.setUint32(16, crc, true);
|
||||
cd.setUint32(20, dataB.length, true);
|
||||
cd.setUint32(24, dataB.length, true);
|
||||
cd.setUint16(28, nameB.length, true);
|
||||
cd.setUint16(30, 0, true);
|
||||
cd.setUint16(32, 0, true);
|
||||
cd.setUint16(34, 0, true);
|
||||
cd.setUint16(36, 0, true);
|
||||
cd.setUint32(38, 0, true);
|
||||
cd.setUint32(42, offset, true);
|
||||
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
|
||||
offset += 30 + nameB.length + dataB.length;
|
||||
}
|
||||
|
||||
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
|
||||
const cdStart = offset;
|
||||
const eocd = new DataView(new ArrayBuffer(22));
|
||||
eocd.setUint32(0, 0x06054b50, true);
|
||||
eocd.setUint16(4, 0, true);
|
||||
eocd.setUint16(6, 0, true);
|
||||
eocd.setUint16(8, entries.length, true);
|
||||
eocd.setUint16(10, entries.length, true);
|
||||
eocd.setUint32(12, cdSize, true);
|
||||
eocd.setUint32(16, cdStart, true);
|
||||
eocd.setUint16(20, 0, true);
|
||||
chunks.push(...central, Buffer.from(eocd.buffer));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
// ── Сборка ZIP с методом 8 (deflate) — для проверки inflateRaw ──
|
||||
async function deflateRaw(bytes) {
|
||||
// zlib.deflateRawSync — deflate без zlib-заголовка (то, что ждёт inflateRaw)
|
||||
return deflateRawSync(Buffer.from(bytes));
|
||||
}
|
||||
|
||||
async function makeZipDeflate(entries) {
|
||||
const enc = new TextEncoder();
|
||||
const chunks = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
const utf8Flag = 0x0800;
|
||||
for (const e of entries) {
|
||||
const nameB = enc.encode(e.name);
|
||||
const raw = enc.encode(e.data);
|
||||
const comp = await deflateRaw(raw);
|
||||
const crc = crc32(raw);
|
||||
const lh = new DataView(new ArrayBuffer(30));
|
||||
lh.setUint32(0, 0x04034b50, true);
|
||||
lh.setUint16(4, 20, true);
|
||||
lh.setUint16(6, utf8Flag, true);
|
||||
lh.setUint16(8, 8, true); // method 8 deflate
|
||||
lh.setUint16(10, 0, true);
|
||||
lh.setUint16(12, 0x21, true);
|
||||
lh.setUint32(14, crc, true);
|
||||
lh.setUint32(18, comp.length, true);
|
||||
lh.setUint32(22, raw.length, true);
|
||||
lh.setUint16(26, nameB.length, true);
|
||||
lh.setUint16(28, 0, true);
|
||||
chunks.push(Buffer.from(lh.buffer), Buffer.from(nameB), Buffer.from(comp));
|
||||
const cd = new DataView(new ArrayBuffer(46));
|
||||
cd.setUint32(0, 0x02014b50, true);
|
||||
cd.setUint16(4, 20, true);
|
||||
cd.setUint16(6, 20, true);
|
||||
cd.setUint16(8, utf8Flag, true);
|
||||
cd.setUint16(10, 8, true);
|
||||
cd.setUint16(12, 0, true);
|
||||
cd.setUint16(14, 0x21, true);
|
||||
cd.setUint32(16, crc, true);
|
||||
cd.setUint32(20, comp.length, true);
|
||||
cd.setUint32(24, raw.length, true);
|
||||
cd.setUint16(28, nameB.length, true);
|
||||
cd.setUint16(30, 0, true);
|
||||
cd.setUint16(32, 0, true);
|
||||
cd.setUint16(34, 0, true);
|
||||
cd.setUint16(36, 0, true);
|
||||
cd.setUint32(38, 0, true);
|
||||
cd.setUint32(42, offset, true);
|
||||
central.push(Buffer.from(cd.buffer), Buffer.from(nameB));
|
||||
offset += 30 + nameB.length + comp.length;
|
||||
}
|
||||
const cdSize = central.reduce((s, x) => s + x.byteLength, 0);
|
||||
const cdStart = offset;
|
||||
const eocd = new DataView(new ArrayBuffer(22));
|
||||
eocd.setUint32(0, 0x06054b50, true);
|
||||
eocd.setUint16(4, 0, true);
|
||||
eocd.setUint16(6, 0, true);
|
||||
eocd.setUint16(8, entries.length, true);
|
||||
eocd.setUint16(10, entries.length, true);
|
||||
eocd.setUint32(12, cdSize, true);
|
||||
eocd.setUint32(16, cdStart, true);
|
||||
eocd.setUint16(20, 0, true);
|
||||
chunks.push(...central, Buffer.from(eocd.buffer));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
// ── Тесты decodeZipName ──
|
||||
function testDecodeZipName() {
|
||||
const utf8 = new TextEncoder().encode('договор.pdf');
|
||||
assert.equal(decodeZipName(utf8, true), 'договор.pdf');
|
||||
// Без UTF-8-флага, но байты — валидный UTF-8 с кириллицей → эвристика берёт как есть
|
||||
assert.equal(decodeZipName(utf8, false), 'договор.pdf');
|
||||
console.log(' decodeZipName: ok');
|
||||
}
|
||||
|
||||
// ── Тесты listZipFiles ──
|
||||
async function testZipCyrillic() {
|
||||
const zip = makeZip([{ name: 'договор.pdf', data: '%PDF-1.4 fake' }]);
|
||||
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, 'договор.pdf');
|
||||
console.log(' zip кириллица: ok');
|
||||
}
|
||||
|
||||
async function testZipNested() {
|
||||
const inner = makeZip([{ name: 'inner.txt', data: 'hi' }]);
|
||||
const outer = makeZip([
|
||||
{ name: 'inner.zip', data: inner },
|
||||
{ name: 'skip.txt', data: 'x' },
|
||||
]);
|
||||
const files = await listZipFiles(new File([outer], 'outer.zip'), ALLOWED);
|
||||
const names = files.map(f => f.name).sort();
|
||||
assert.deepEqual(names, ['inner.txt', 'skip.txt']);
|
||||
console.log(' вложенный zip: ok');
|
||||
}
|
||||
|
||||
async function testZipNonDoc() {
|
||||
const zip = makeZip([
|
||||
{ name: 'doc.pdf', data: 'pdf' },
|
||||
{ name: 'img.png', data: 'png' },
|
||||
{ name: 'readme.txt', data: 'readme' },
|
||||
]);
|
||||
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
|
||||
const names = files.map(f => f.name).sort();
|
||||
assert.deepEqual(names, ['doc.pdf', 'readme.txt']);
|
||||
console.log(' не-doc фильтр: ok');
|
||||
}
|
||||
|
||||
async function testZipDeflate() {
|
||||
// deflate-raw требует DecompressionStream('deflate-raw') — в браузере есть, в Node 18 нет
|
||||
let supported = true;
|
||||
try { new DecompressionStream('deflate-raw'); } catch (e) { supported = false; }
|
||||
if (!supported) {
|
||||
console.log(' zip deflate (метод 8): skip — Node 18 не поддерживает deflate-raw');
|
||||
return;
|
||||
}
|
||||
const content = 'Hello deflate world '.repeat(50);
|
||||
const zip = await makeZipDeflate([{ name: 'deflated.txt', data: content }]);
|
||||
const files = await listZipFiles(new File([zip], 'a.zip'), ALLOWED);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0].name, 'deflated.txt');
|
||||
assert.equal(await files[0].text(), content);
|
||||
console.log(' zip deflate (метод 8): ok');
|
||||
}
|
||||
|
||||
async function testParseZipNotZip() {
|
||||
await assert.rejects(() => parseZip(new Uint8Array([1, 2, 3])), /Не ZIP/);
|
||||
console.log(' не-ZIP бросок: ok');
|
||||
}
|
||||
|
||||
// ── Тесты addFileWithDedup ──
|
||||
function newState() {
|
||||
return { files: [], fileMeta: new Map(), overNames: new Set(), busy: false, proc: null };
|
||||
}
|
||||
|
||||
function testDedup() {
|
||||
const st = newState();
|
||||
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
|
||||
// одинаковое имя+размер → дедуп (false), список не растёт
|
||||
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), true);
|
||||
assert.equal(st.files.length, 1);
|
||||
assert.equal(addFileWithDedup(st, new File(['aaa'], 'a.txt'), c), false);
|
||||
assert.equal(st.files.length, 1);
|
||||
// имя то же, размер другой → суффикс _2
|
||||
assert.equal(addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c), true);
|
||||
assert.equal(st.files.length, 2);
|
||||
assert.equal(st.files[1].name, 'a_2.txt');
|
||||
console.log(' дедуп/суффиксы: ok');
|
||||
}
|
||||
|
||||
function testLimits() {
|
||||
// лимит на один файл
|
||||
const st = newState();
|
||||
const c = { maxFileBytes: 100, maxSessionBytes: 300 };
|
||||
addFileWithDedup(st, new File([new Uint8Array(150)], 'big.bin'), c);
|
||||
assert.ok(st.overNames.has('big.bin'));
|
||||
assert.equal(st.files.length, 1);
|
||||
// суммарный лимит сессии
|
||||
const st2 = newState();
|
||||
const c2 = { maxFileBytes: 1000, maxSessionBytes: 200 };
|
||||
addFileWithDedup(st2, new File([new Uint8Array(150)], 'f1.bin'), c2);
|
||||
addFileWithDedup(st2, new File([new Uint8Array(60)], 'f2.bin'), c2); // 150+60=210 > 200 → over
|
||||
assert.ok(st2.overNames.has('f2.bin'));
|
||||
console.log(' лимиты over: ok');
|
||||
}
|
||||
|
||||
function testSuffixes() {
|
||||
const st = newState();
|
||||
const c = { maxFileBytes: 10000, maxSessionBytes: 100000 };
|
||||
addFileWithDedup(st, new File(['aaa'], 'a.txt'), c);
|
||||
addFileWithDedup(st, new File(['bbbb'], 'a.txt'), c); // a_2.txt
|
||||
addFileWithDedup(st, new File(['ccccc'], 'a.txt'), c); // a_3.txt
|
||||
assert.deepEqual(st.files.map(f => f.name), ['a.txt', 'a_2.txt', 'a_3.txt']);
|
||||
console.log(' суффиксы _2/_3: ok');
|
||||
}
|
||||
|
||||
function testEsc() {
|
||||
assert.equal(esc('<a href="x">&\'</a>'), '<a href="x">&'</a>');
|
||||
console.log(' esc (XSS-экранирование): ok');
|
||||
}
|
||||
|
||||
function testFs() {
|
||||
assert.equal(fs(0), '0 B');
|
||||
assert.equal(fs(1023), '1023 B');
|
||||
assert.equal(fs(1024), '1.0 KB');
|
||||
assert.equal(fs(1048576), '1.0 MB');
|
||||
console.log(' fs (размер): ok');
|
||||
}
|
||||
|
||||
function testFmtSec() {
|
||||
assert.equal(fmtSec(0), '0с');
|
||||
assert.equal(fmtSec(59), '59с');
|
||||
assert.equal(fmtSec(60), '1м 0с');
|
||||
assert.equal(fmtSec(125), '2м 5с');
|
||||
console.log(' fmtSec: ok');
|
||||
}
|
||||
|
||||
function testEstForFile() {
|
||||
assert.equal(estForFile({ size: 1048576 }, 12), 12); // 1 МБ × 12 = 12с
|
||||
assert.equal(estForFile(null, 12), 0);
|
||||
console.log(' estForFile: ok');
|
||||
}
|
||||
|
||||
// ── Прогон ──
|
||||
const TESTS = [
|
||||
['decodeZipName', testDecodeZipName],
|
||||
['zip кириллица', testZipCyrillic],
|
||||
['вложенный zip', testZipNested],
|
||||
['не-doc фильтр', testZipNonDoc],
|
||||
['zip deflate (метод 8)', testZipDeflate],
|
||||
['не-ZIP бросок', testParseZipNotZip],
|
||||
['дедуп/суффиксы', testDedup],
|
||||
['лимиты over', testLimits],
|
||||
['суффиксы _2/_3', testSuffixes],
|
||||
['esc XSS', testEsc],
|
||||
['fs размер', testFs],
|
||||
['fmtSec', testFmtSec],
|
||||
['estForFile', testEstForFile],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const [name, fn] of TESTS) {
|
||||
try {
|
||||
await fn();
|
||||
console.log('PASS ' + name);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error('FAIL ' + name + ': ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(failed + ' тестов упало');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('Все фронт-тесты прошли');
|
||||
@@ -1,199 +0,0 @@
|
||||
// Юнит-тесты слоя 2 (фронт): putToVm + uploadViaVM с моками XMLHttpRequest и fetch.
|
||||
// Запуск: node upload/frontend/test_upload_layer2.mjs
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// ── Полифилл File (Node 18 не имеет global File) ──
|
||||
if (typeof globalThis.File === 'undefined') {
|
||||
globalThis.File = class File extends Blob {
|
||||
constructor(parts, name, opts) {
|
||||
super(parts, opts);
|
||||
this.name = name;
|
||||
this.lastModified = (opts && opts.lastModified) || Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
import { putToVm } from './upload/put_to_vm.js';
|
||||
import { uploadViaVM } from './upload/upload_via_vm.js';
|
||||
|
||||
const tick = () => new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// Node 18 не имеет globalThis.crypto (в браузере есть) — заглушка для uploadViaVM
|
||||
globalThis.crypto = globalThis.crypto || { randomUUID: () => 'test-uuid-1234' };
|
||||
|
||||
// ── мок XMLHttpRequest ──
|
||||
let lastXHR = null;
|
||||
class FakeXHR {
|
||||
constructor() {
|
||||
this.upload = {};
|
||||
this.status = 0;
|
||||
this.timeout = 0;
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.ontimeout = null;
|
||||
this.method = null;
|
||||
this.url = null;
|
||||
this.body = null;
|
||||
lastXHR = this;
|
||||
}
|
||||
open(method, url) { this.method = method; this.url = url; }
|
||||
send(body) { this.body = body; }
|
||||
}
|
||||
globalThis.XMLHttpRequest = FakeXHR;
|
||||
|
||||
// ── мок fetch ──
|
||||
let fetchCalls = [];
|
||||
function installFetch(respond) {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
fetchCalls.push({ url, opts });
|
||||
return respond();
|
||||
};
|
||||
}
|
||||
|
||||
// ── putToVm ──
|
||||
async function testPutToVmSuccess() {
|
||||
const f = new File(['abc'], 'a.txt');
|
||||
const p = putToVm(f, 'https://vm/tok_0');
|
||||
lastXHR.status = 200;
|
||||
lastXHR.onload();
|
||||
await p;
|
||||
assert.equal(lastXHR.method, 'PUT');
|
||||
assert.equal(lastXHR.url, 'https://vm/tok_0');
|
||||
assert.equal(lastXHR.body, f);
|
||||
console.log(' putToVm success: ok');
|
||||
}
|
||||
|
||||
async function testPutToVmHttpError() {
|
||||
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
|
||||
lastXHR.status = 500;
|
||||
lastXHR.onload();
|
||||
await assert.rejects(p, /ВМ: HTTP 500/);
|
||||
console.log(' putToVm HTTP error: ok');
|
||||
}
|
||||
|
||||
async function testPutToVmNetworkError() {
|
||||
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
|
||||
lastXHR.onerror();
|
||||
await assert.rejects(p, /Сеть/);
|
||||
console.log(' putToVm network error: ok');
|
||||
}
|
||||
|
||||
async function testPutToVmTimeout() {
|
||||
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0');
|
||||
lastXHR.ontimeout();
|
||||
await assert.rejects(p, /Таймаут/);
|
||||
console.log(' putToVm timeout: ok');
|
||||
}
|
||||
|
||||
async function testPutToVmOnXHR() {
|
||||
let registered = null;
|
||||
const p = putToVm(new File(['abc'], 'a.txt'), 'https://vm/tok_0', { onXHR(x) { registered = x; } });
|
||||
assert.ok(registered, 'onXHR должен получить XHR для отмены');
|
||||
registered.status = 200;
|
||||
registered.onload();
|
||||
await p;
|
||||
console.log(' putToVm onXHR регистрация: ok');
|
||||
}
|
||||
|
||||
// ── uploadViaVM ──
|
||||
async function testUploadViaVMSuccess() {
|
||||
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'sid123', count: 2 }) }));
|
||||
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
|
||||
const p = uploadViaVM(files, 'https://vm/', { session: '' });
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
const res = await p;
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.session, 'sid123');
|
||||
assert.equal(res.count, 2);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
const body = JSON.parse(fetchCalls[0].opts.body);
|
||||
assert.equal(body.files.length, 2);
|
||||
assert.equal(body.files[0].name, 'a.txt');
|
||||
assert.equal(body.files[1].name, 'b.txt');
|
||||
assert.ok(body.files[0].url.startsWith('https://vm/'));
|
||||
console.log(' uploadViaVM success: ok');
|
||||
}
|
||||
|
||||
async function testUploadViaVMPutError() {
|
||||
installFetch(() => ({ ok: true, json: async () => ({}) }));
|
||||
const files = [new File(['a'], 'a.txt'), new File(['bb'], 'b.txt')];
|
||||
const p = uploadViaVM(files, 'https://vm/', { session: '' });
|
||||
await tick();
|
||||
lastXHR.onerror(); // первый PUT падает
|
||||
const res = await p;
|
||||
assert.equal(res.ok, false);
|
||||
assert.ok(res.error.includes('Ошибка загрузки на ВМ'));
|
||||
assert.equal(fetchCalls.length, 0); // POST не вызван
|
||||
console.log(' uploadViaVM PUT error: ok');
|
||||
}
|
||||
|
||||
async function testUploadViaVMPostError() {
|
||||
installFetch(() => ({ ok: true, json: async () => ({ ok: false, error: 'Session not found' }) }));
|
||||
const files = [new File(['a'], 'a.txt')];
|
||||
const p = uploadViaVM(files, 'https://vm/', { session: '' });
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
const res = await p;
|
||||
assert.equal(res.ok, false);
|
||||
assert.ok(res.error.includes('Ошибка передачи ссылок'));
|
||||
console.log(' uploadViaVM POST error: ok');
|
||||
}
|
||||
|
||||
async function testUploadViaVMProgress() {
|
||||
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 's', count: 1 }) }));
|
||||
const statuses = [];
|
||||
const texts = [];
|
||||
const files = [new File(['abc'], 'a.txt')];
|
||||
const p = uploadViaVM(files, 'https://vm/', {
|
||||
session: '',
|
||||
onStatus(k, html) { statuses.push({ k, html }); },
|
||||
onUploadStatus(t) { texts.push(t); },
|
||||
});
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
await p;
|
||||
assert.ok(statuses.some(s => s.html.includes('✓')));
|
||||
assert.ok(texts.some(t => t.includes('Загрузка на ВМ')));
|
||||
assert.ok(texts.some(t => t.includes('Передача ссылок')));
|
||||
console.log(' uploadViaVM progress/статусы: ok');
|
||||
}
|
||||
|
||||
async function testUploadViaVMSessionPassed() {
|
||||
// session из options пробрасывается в POST-тело
|
||||
installFetch(() => ({ ok: true, json: async () => ({ ok: true, session: 'prev', count: 1 }) }));
|
||||
const files = [new File(['a'], 'a.txt')];
|
||||
const p = uploadViaVM(files, 'https://vm/', { session: 'existingsid' });
|
||||
await tick();
|
||||
lastXHR.status = 200; lastXHR.onload();
|
||||
await p;
|
||||
const body = JSON.parse(fetchCalls[0].opts.body);
|
||||
assert.equal(body.session, 'existingsid');
|
||||
console.log(' uploadViaVM проброс session: ok');
|
||||
}
|
||||
|
||||
// ── прогон ──
|
||||
const TESTS = [
|
||||
['putToVm success', testPutToVmSuccess],
|
||||
['putToVm HTTP error', testPutToVmHttpError],
|
||||
['putToVm network error', testPutToVmNetworkError],
|
||||
['putToVm timeout', testPutToVmTimeout],
|
||||
['putToVm onXHR', testPutToVmOnXHR],
|
||||
['uploadViaVM success', testUploadViaVMSuccess],
|
||||
['uploadViaVM PUT error', testUploadViaVMPutError],
|
||||
['uploadViaVM POST error', testUploadViaVMPostError],
|
||||
['uploadViaVM progress', testUploadViaVMProgress],
|
||||
['uploadViaVM проброс session', testUploadViaVMSessionPassed],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const [name, fn] of TESTS) {
|
||||
try { await fn(); console.log('PASS ' + name); }
|
||||
catch (e) { failed++; console.error('FAIL ' + name + ': ' + e.message); }
|
||||
}
|
||||
if (failed) { console.error(failed + ' тестов упало'); process.exit(1); }
|
||||
console.log('Все тесты слоя 2 (фронт) прошли');
|
||||
@@ -1,23 +1,78 @@
|
||||
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело).
|
||||
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx.
|
||||
|
||||
/**
|
||||
* putToVm — отправка одного файла в буфер методом HTTP PUT (сырое бинарное тело).
|
||||
*
|
||||
* @param {Blob|File} file Файл для отправки.
|
||||
* @param {string} url Полный целевой URL в буфере.
|
||||
* @param {Object} options Опции отправки:
|
||||
* @param {function(number, number, number): void} [options.onProgress] Колбэк прогресса (pct, loaded, total).
|
||||
* @param {function(XMLHttpRequest): void} [options.onXHR] Колбэк регистрации XHR для прямого контроля.
|
||||
* @param {AbortSignal} [options.signal] Сигнал прерывания запроса (AbortController.signal).
|
||||
* @param {number} [options.timeoutMs=300000] Таймаут в миллисекундах (по умолчанию 300с).
|
||||
* @returns {Promise<void>} Разрешается при ответе 2xx, отклоняется при ошибке или прерывании.
|
||||
*/
|
||||
export function putToVm(file, url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
if (options.onXHR) options.onXHR(xhr); // регистрация для отмены (abort)
|
||||
|
||||
if (options.onXHR) {
|
||||
options.onXHR(xhr);
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
xhr.abort();
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
xhr.open('PUT', url);
|
||||
xhr.timeout = options.timeoutMs || 300000; // 300с: большие файлы
|
||||
xhr.upload.onprogress = function(e) {
|
||||
xhr.timeout = options.timeoutMs || 300000;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && options.onProgress) {
|
||||
options.onProgress(Math.round(e.loaded / e.total * 100));
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
options.onProgress(pct, e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
xhr.onload = function() {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error('ВМ: HTTP ' + xhr.status));
|
||||
|
||||
xhr.onload = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Буфер: HTTP ${xhr.status} ${xhr.statusText}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
|
||||
xhr.send(file); // сырое тело файла
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(new Error('Сетевая ошибка при отправке в буфер'));
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(new Error(`Таймаут ожидания ответа буфера (${Math.round(xhr.timeout / 1000)}с)`));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,59 +1,188 @@
|
||||
// uploadViaVM — слой 2 (фронт): закачать файлы через ВМ-буфер.
|
||||
// Шаг 1: PUT каждого файла на ВМ (token-ключ в URL). Шаг 2: POST /api/upload_refs.
|
||||
//
|
||||
// files: File[] — ТОЛЬКО учитываемые (без сверхлимитных).
|
||||
// vmUploadUrl: базовый URL ВМ-буфера (напр. "https://.../drhider-upload/").
|
||||
// options: {
|
||||
// session, // текущий sid (или '')
|
||||
// apiBase, // базовый путь бэка, по умолчанию '' (тот же origin)
|
||||
// onUploadStatus, // (text) — сообщение статуса
|
||||
// onStatus, // (k, html) — статус в таблице для k-го файла
|
||||
// }
|
||||
// Returns: Promise<{ok, session, count} | {ok:false, error}>
|
||||
/**
|
||||
* uploadViaVM — Слой 2 (Фронтенд): пофайловый транзит через ВМ-буфер в сессию бэкенда.
|
||||
*
|
||||
* Архитектура потокового транзита:
|
||||
* Для каждого файла k:
|
||||
* 1. Браузер отправляет файл k методом PUT в буфер на ВМ.
|
||||
* 2. Браузер сразу делает POST /api/upload_refs для одного файла k.
|
||||
* 3. Бэкенд забирает файл исходящим GET в память RAM и сразу шлёт DELETE в буфер.
|
||||
* 4. Файл удалён из буфера ВМ, память чиста.
|
||||
* 5. Браузер переходит к файлу k+1.
|
||||
*/
|
||||
|
||||
import { putToVm } from './put_to_vm.js';
|
||||
import { fs } from '../table/fs.js';
|
||||
|
||||
export async function uploadViaVM(files, vmUploadUrl, options = {}) {
|
||||
const token = crypto.randomUUID();
|
||||
const refs = []; // {name, size, url} — для POST /api/upload_refs
|
||||
const total = files.length;
|
||||
function generateUuid() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует элемент списка файлов (поддерживает как File, так и объект из FilePicker.getFiles()).
|
||||
*/
|
||||
function normalizeFileEntry(item) {
|
||||
if (item instanceof File || item instanceof Blob) {
|
||||
return { name: item.name || 'unnamed', size: item.size, file: item, path: item.name || '' };
|
||||
}
|
||||
if (item && item.file) {
|
||||
return {
|
||||
name: item.name || item.file.name || 'unnamed',
|
||||
size: item.size ?? item.file.size ?? 0,
|
||||
file: item.file,
|
||||
path: item.path || item.name || '',
|
||||
};
|
||||
}
|
||||
throw new Error('Некорректный объект файла');
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет пофайловую транзитную загрузку файлов в сессию бэкенда через буфер.
|
||||
*
|
||||
* @param {Array<File|{name: string, size: number, file: File, path?: string}>} files Список файлов.
|
||||
* @param {Object} options Параметры загрузки:
|
||||
* @param {string} options.vmUploadUrl Базовый URL буфера (обязателен).
|
||||
* @param {string} [options.backendUploadUrl='/api/upload_refs'] URL эндпоинта приёма ссылок.
|
||||
* @param {string} [options.session=''] Идентификатор существующей сессии (если есть).
|
||||
* @param {AbortSignal} [options.signal] Сигнал отмены загрузки.
|
||||
* @param {function(string): void} [options.onStatus] Колбэк общего текстового статуса.
|
||||
* @param {function(number, string): void} [options.onFileStatus] Колбэк обновления статуса конкретного файла (индекс, текст/html).
|
||||
* @param {function(Object): void} [options.onProgress] Колбэк прогресса текущего файла ({ index, total, pct, speed }).
|
||||
* @param {function(Object): void} [options.onFileComplete] Колбэк успешной доставки файла ({ index, name, session, totalAdded }).
|
||||
* @returns {Promise<{ok: boolean, session?: string, count?: number, error?: string, aborted?: boolean}>}
|
||||
*/
|
||||
export async function uploadViaVM(files, options = {}) {
|
||||
if (!options.vmUploadUrl) {
|
||||
return { ok: false, error: 'Параметр options.vmUploadUrl обязателен' };
|
||||
}
|
||||
|
||||
const normalizedFiles = Array.from(files).map(normalizeFileEntry);
|
||||
const total = normalizedFiles.length;
|
||||
if (total === 0) {
|
||||
return { ok: true, session: options.session || '', count: 0 };
|
||||
}
|
||||
|
||||
const backendUrl = options.backendUploadUrl || (options.apiBase || '') + '/api/upload_refs';
|
||||
const vmBase = options.vmUploadUrl.endsWith('/') ? options.vmUploadUrl : `${options.vmUploadUrl}/`;
|
||||
let currentSession = options.session || '';
|
||||
let totalAdded = 0;
|
||||
|
||||
for (let k = 0; k < total; k++) {
|
||||
const f = files[k];
|
||||
const vmUrl = vmUploadUrl + token + '_' + k; // имя файла в URL не несём (токен-ключ)
|
||||
if (options.onUploadStatus) {
|
||||
options.onUploadStatus('Загрузка на ВМ (этап 1/2) ' + (k + 1) + '/' + total + ': ' + f.name);
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
|
||||
const { name, size, file } = normalizedFiles[k];
|
||||
const fileKey = `${generateUuid()}_${k}`;
|
||||
const fileUrl = `${vmBase}${fileKey}`;
|
||||
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Загрузка в буфер (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '⏳ В буфер...');
|
||||
}
|
||||
|
||||
// Этап 1: Отправка одного файла в буфер
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
await putToVm(f, vmUrl, {
|
||||
onProgress(pct) {
|
||||
if (options.onStatus) options.onStatus(k, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
|
||||
},
|
||||
await putToVm(file, fileUrl, {
|
||||
signal: options.signal,
|
||||
onXHR: options.onXHR,
|
||||
onProgress(pct, loaded, fileTotal) {
|
||||
const elapsed = (performance.now() - t0) / 1000;
|
||||
const speed = elapsed > 0 ? (loaded / elapsed) : 0;
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, `⏳ ${pct}% (${fs(speed)}/s)`);
|
||||
}
|
||||
if (options.onProgress) {
|
||||
options.onProgress({ index: k, total, pct, speed, loaded, fileTotal, name });
|
||||
}
|
||||
},
|
||||
});
|
||||
const elapsed = (performance.now() - t0) / 1000;
|
||||
const speed = f.size / elapsed;
|
||||
if (options.onStatus) options.onStatus(k, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
|
||||
refs.push({ name: f.name, size: f.size, url: vmUrl });
|
||||
} catch (err) {
|
||||
if (options.onStatus) options.onStatus(k, '<span style="color:#ef4444">✗</span>');
|
||||
return { ok: false, error: 'Ошибка загрузки на ВМ: ' + err.message };
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '✗ Ошибка буфера');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Ошибка отправки файла ${name} в буфер: ${err.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession,
|
||||
};
|
||||
}
|
||||
|
||||
// Этап 2: Вызов /api/upload_refs для немедленного переноса файла с буфера в RAM сессии
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Приём сервисом (${k + 1}/${total}): ${name}`);
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '⚡ В сессию...');
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(backendUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session: currentSession,
|
||||
files: [{ name, size, url: fileUrl }],
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Бэкенд вернул ошибку');
|
||||
}
|
||||
|
||||
currentSession = data.session || currentSession;
|
||||
totalAdded = data.count ?? (totalAdded + 1);
|
||||
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '✓ Доставлен');
|
||||
}
|
||||
if (options.onFileComplete) {
|
||||
options.onFileComplete({
|
||||
fileIndex: k,
|
||||
name,
|
||||
size,
|
||||
session: currentSession,
|
||||
totalAdded,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (options.signal?.aborted) {
|
||||
return { ok: false, error: 'Загрузка отменена', aborted: true, session: currentSession };
|
||||
}
|
||||
if (options.onFileStatus) {
|
||||
options.onFileStatus(k, '✗ Ошибка приёма');
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `Ошибка приёма файла ${name} бэкендом: ${err.message}`,
|
||||
failedIndex: k,
|
||||
session: currentSession,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
|
||||
if (options.onUploadStatus) options.onUploadStatus('Передача ссылок в сервис…');
|
||||
try {
|
||||
const resp = await fetch((options.apiBase || '') + '/api/upload_refs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session: options.session || '', files: refs })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
|
||||
return { ok: true, session: data.session, count: data.count || refs.length };
|
||||
} catch (err) {
|
||||
return { ok: false, error: 'Ошибка передачи ссылок: ' + err.message };
|
||||
if (options.onStatus) {
|
||||
options.onStatus(`Завершено. Успешно передано файлов: ${total}`);
|
||||
}
|
||||
|
||||
return { ok: true, session: currentSession, count: totalAdded };
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// decodeZipName — декодирование имени из ZIP: UTF-8-флаг / эвристика (UTF-8 → CP437 → CP866).
|
||||
|
||||
export function decodeZipName(bytes, isUtf8) {
|
||||
if (isUtf8) return new TextDecoder('utf-8').decode(bytes);
|
||||
// Многие архиваторы пишут имя в UTF-8, но НЕ выставляют UTF-8-флаг (bit 11).
|
||||
// Сначала строго пробуем UTF-8: если байты — валидный UTF-8 с кириллицей/текстом,
|
||||
// берём их как есть (иначе декодирование CP437→CP866 превратит их в «╨╣…»-мусор).
|
||||
try {
|
||||
const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
// Кириллица — точно UTF-8; либо чистый печатаемый текст без управляющих символов.
|
||||
if (/[\u0400-\u04FF]/.test(s) || !/[^\u0020-\u007e]/.test(s)) return s;
|
||||
} catch (e) { /* не UTF-8 — legacy (CP437/CP866) */ }
|
||||
let name;
|
||||
try { name = new TextDecoder('ibm437').decode(bytes); }
|
||||
catch (e) { name = new TextDecoder('utf-8').decode(bytes); }
|
||||
// Кириллица из 1С (CP866) — перекодировать, если имя пришло как CP437-мусор
|
||||
if (/[^\x00-\x7f]/.test(name)) {
|
||||
try { name = new TextDecoder('ibm866').decode(bytes); }
|
||||
catch (e) { /* оставить как есть */ }
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// dosToMs — преобразование DOS-даты/времени (ZIP) в миллисекунды (unix epoch).
|
||||
|
||||
export function dosToMs(date, time) {
|
||||
const year = 1980 + ((date >> 9) & 0x7f);
|
||||
const month = (date >> 5) & 0x0f;
|
||||
const day = date & 0x1f;
|
||||
const hour = (time >> 11) & 0x1f;
|
||||
const min = (time >> 5) & 0x3f;
|
||||
const sec = (time & 0x1f) * 2;
|
||||
return new Date(year, month - 1, day, hour, min, sec).getTime();
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// inflateRaw — распаковка deflate-raw (метод 8) через нативный DecompressionStream.
|
||||
|
||||
export async function inflateRaw(bytes) {
|
||||
const ds = new DecompressionStream('deflate-raw');
|
||||
const stream = new Blob([bytes]).stream().pipeThrough(ds);
|
||||
const ab = await new Response(stream).arrayBuffer();
|
||||
return new Uint8Array(ab);
|
||||
}
|
||||
@@ -1,22 +1,125 @@
|
||||
// listZipFiles — рекурсивно достать из ZIP только документы (вложенные zip — разворачиваются).
|
||||
// Построить дерево ZIP с файлами только разрешённых расширений.
|
||||
import { unzipSync } from 'fflate';
|
||||
import { rebaseTree } from '../table/rebase_tree.js';
|
||||
|
||||
import { parseZip } from './parse_zip.js';
|
||||
const DEFAULT_LIMITS = {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
};
|
||||
|
||||
export async function listZipFiles(file, allowedExt) {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const entries = await parseZip(buf);
|
||||
const out = [];
|
||||
// Из ZIP вытаскиваем только документы. Всё прочее (изображения и т.п.) пропускаем.
|
||||
for (const e of entries) {
|
||||
if (e.isDir) continue;
|
||||
const low = e.name.toLowerCase();
|
||||
if (low.endsWith('.zip')) {
|
||||
const sub = new File([e.data], e.name, { lastModified: e.dosMs });
|
||||
out.push(...(await listZipFiles(sub, allowedExt)));
|
||||
} else if (allowedExt.some(ext => low.endsWith(ext))) {
|
||||
out.push(new File([e.data], e.name, { lastModified: e.dosMs }));
|
||||
}
|
||||
// иначе — не документ, пропускаем
|
||||
}
|
||||
return out;
|
||||
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Создаёт browser File из байтов ZIP entry с базовым именем файла. */
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name.split('/').at(-1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует путь ZIP entry до его использования в дереве.
|
||||
*
|
||||
* @param {string} entryName Сырой путь из центрального каталога ZIP.
|
||||
* @returns {string[]|null} Безопасные сегменты пути или null для опасной записи.
|
||||
*
|
||||
* Отклоняются абсолютные пути, backslash, пустые сегменты, `.` и `..`.
|
||||
* Это предотвращает traversal и появление ложных групп в UI.
|
||||
*/
|
||||
function safeEntryParts(entryName) {
|
||||
if (!entryName || entryName.startsWith('/') || entryName.includes('\\')) return null;
|
||||
const parts = entryName.split('/');
|
||||
if (parts.some((part) => !part || part === '.' || part === '..')) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Создаёт единый узел file, folder или zip с уникальным id и начальным состоянием раскрытия. */
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: kind === 'file' };
|
||||
}
|
||||
|
||||
/** Вставляет leaf или вложенное дерево по сегментам пути, создавая folder-узлы. */
|
||||
function addPath(root, parts, fileNode) {
|
||||
let current = root;
|
||||
parts.forEach((part, index) => {
|
||||
const last = index === parts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last
|
||||
? fileNode
|
||||
: node('folder', part, `${current.path}/${part}`);
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно читает байтовый массив ZIP и строит дерево разрешённых файлов.
|
||||
*
|
||||
* @param {Uint8Array} data Распаковываемые байты ZIP.
|
||||
* @param {string} zipName Имя текущего архива для корневого узла и пути.
|
||||
* @param {string[]} allowedExt Разрешённые расширения документов.
|
||||
* @param {number} depth Текущая глубина вложенных ZIP.
|
||||
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
|
||||
* @throws {Error} При превышении глубины или повреждённом ZIP.
|
||||
*/
|
||||
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
|
||||
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
|
||||
const entries = unzipSync(data, {
|
||||
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');
|
||||
}
|
||||
budget.totalBytes += entry.originalSize;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const root = node('zip', zipName, zipName);
|
||||
|
||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||
// Каталоги ZIP не являются файлами и будут созданы addPath при необходимости.
|
||||
if (entryName.endsWith('/')) continue;
|
||||
const parts = safeEntryParts(entryName);
|
||||
if (!parts) continue;
|
||||
const normalizedName = parts.join('/');
|
||||
if (normalizedName.toLowerCase().endsWith('.zip')) {
|
||||
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
|
||||
if (nested) {
|
||||
rebaseTree(nested, zipName);
|
||||
nested.name = parts.at(-1);
|
||||
addPath(root, parts, nested);
|
||||
}
|
||||
} else if (extensionAllowed(normalizedName, allowedExt)) {
|
||||
// Запрещённые документы отбрасываются до создания browser File.
|
||||
const path = `${zipName}/${normalizedName}`;
|
||||
const file = makeFile(entryData, path);
|
||||
addPath(root, parts, node('file', parts.at(-1), path, [], file));
|
||||
}
|
||||
}
|
||||
return root.children.length ? root : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Публично читает выбранный ZIP-файл и возвращает его фильтрованное дерево.
|
||||
*
|
||||
* @param {File} file Browser File с ZIP-содержимым.
|
||||
* @param {string[]} allowedExt Разрешённые расширения.
|
||||
* @param {object} customLimits Ограничения распаковки.
|
||||
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
|
||||
*/
|
||||
export async function listZipFiles(file, allowedExt, customLimits = {}) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const limits = { ...DEFAULT_LIMITS, ...customLimits };
|
||||
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// parseZip — разбор ZIP: центральный каталог → записи {name, data, dosMs, isDir}.
|
||||
// Поддерживаются методы 0 (store) и 8 (deflate). Без внешних библиотек.
|
||||
|
||||
import { decodeZipName } from './decode_zip_name.js';
|
||||
import { inflateRaw } from './inflate_raw.js';
|
||||
import { dosToMs } from './dos_to_ms.js';
|
||||
|
||||
export async function parseZip(buf) {
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0; i--) {
|
||||
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('Не ZIP');
|
||||
const cdSize = dv.getUint32(eocd + 12, true);
|
||||
const cdOffset = dv.getUint32(eocd + 16, true);
|
||||
|
||||
const entries = [];
|
||||
let pos = cdOffset;
|
||||
const cdEnd = cdOffset + cdSize;
|
||||
while (pos < cdEnd) {
|
||||
if (dv.getUint32(pos, true) !== 0x02014b50) break;
|
||||
const flags = dv.getUint16(pos + 8, true);
|
||||
const method = dv.getUint16(pos + 10, true);
|
||||
const modTime = dv.getUint16(pos + 12, true);
|
||||
const modDate = dv.getUint16(pos + 14, true);
|
||||
const compSize = dv.getUint32(pos + 20, true);
|
||||
const nameLen = dv.getUint16(pos + 28, true);
|
||||
const extraLen = dv.getUint16(pos + 30, true);
|
||||
const commentLen = dv.getUint16(pos + 32, true);
|
||||
const localOffset = dv.getUint32(pos + 42, true);
|
||||
|
||||
const nameBytes = buf.slice(pos + 46, pos + 46 + nameLen);
|
||||
const name = decodeZipName(nameBytes, (flags & 0x800) !== 0);
|
||||
|
||||
const lhNameLen = dv.getUint16(localOffset + 26, true);
|
||||
const lhExtraLen = dv.getUint16(localOffset + 28, true);
|
||||
const dataStart = localOffset + 30 + lhNameLen + lhExtraLen;
|
||||
const comp = buf.slice(dataStart, dataStart + compSize);
|
||||
|
||||
let data;
|
||||
if (method === 0) data = comp;
|
||||
else if (method === 8) data = await inflateRaw(comp);
|
||||
else throw new Error('Метод сжатия ' + method + ' не поддерживается');
|
||||
|
||||
entries.push({ name, data, dosMs: dosToMs(modDate, modTime), isDir: name.endsWith('/') });
|
||||
pos += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
Reference in New Issue
Block a user