этап 2: переиспользуемый модуль upload (sink) вместо рукописного транспорта
- копирую модуль upload/ из drhider (слои 1-2) - blueprint: параметр sink (drhider-сессия по умолчанию, сверка — DB+парсинг) - upload_bp: contracts_upload_sink = _store_and_parse - routes: регистрирую create_upload_refs_blueprint(cfg, sink=...) - app.py: корень репо в sys.path (для import upload) - History: план переиспользования + ревью Соннета
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "upload-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Переиспользуемый фронт слоёв 1 и 2 (выбор файлов + закачка через ВМ). Модули ES — подключаются в браузере через <script type=\"module\">; Node-режим нужен для юнит-тестов zip."
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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; // дедуп: файл не добавлен (обновлена только дата)
|
||||
}
|
||||
// Имя то же, размер другой — добавить с суффиксом _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 }));
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// esc — экранирование HTML (защита от self-XSS именами файлов).
|
||||
|
||||
export function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 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 + 'с';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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';
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Обработчик <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);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (hasZip) {
|
||||
document.body.style.cursor = '';
|
||||
if (onStatus) onStatus('', '');
|
||||
}
|
||||
}
|
||||
// Синхронизировать 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);
|
||||
}
|
||||
|
||||
// Фабрика обработчика 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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Обработчик <input webkitdirectory> change: выбор целой папки, рекурсивно,
|
||||
// относительный путь сохраняется (верхняя папка отбрасывается).
|
||||
|
||||
import { listZipFiles } from '../zip/list_zip_files.js';
|
||||
import { addFileWithDedup } from './add_file_with_dedup.js';
|
||||
import { render } from './render.js';
|
||||
|
||||
// Фабрика обработчика change для input выбора папки.
|
||||
// ctx = { state, cfg, els, onStatus }
|
||||
export function makeFolderChangeHandler(ctx) {
|
||||
const { state, cfg, els, onStatus } = ctx;
|
||||
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;
|
||||
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++;
|
||||
}
|
||||
// иначе — не документ, пропускаем
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// 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,29 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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 + ' файлов';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// setStatus — записать HTML-статус в ячейку таблицы (st-<idx>).
|
||||
|
||||
export function setStatus(idx, html) {
|
||||
const e = document.getElementById('st-' + idx);
|
||||
if (e) e.innerHTML = html;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// Юнит-тесты фронта модуля 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('Все фронт-тесты прошли');
|
||||
@@ -0,0 +1,199 @@
|
||||
// Юнит-тесты слоя 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 (фронт) прошли');
|
||||
@@ -0,0 +1,23 @@
|
||||
// putToVm — PUT одного файла на ВМ-буфер (XHR, сырое тело).
|
||||
// onProgress(pct) — прогресс загрузки. Разрешается при HTTP 2xx.
|
||||
|
||||
export function putToVm(file, url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
if (options.onXHR) options.onXHR(xhr); // регистрация для отмены (abort)
|
||||
xhr.open('PUT', url);
|
||||
xhr.timeout = options.timeoutMs || 300000; // 300с: большие файлы
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable && options.onProgress) {
|
||||
options.onProgress(Math.round(e.loaded / e.total * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = function() {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error('ВМ: HTTP ' + xhr.status));
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
|
||||
xhr.send(file); // сырое тело файла
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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}>
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
await putToVm(f, vmUrl, {
|
||||
onProgress(pct) {
|
||||
if (options.onStatus) options.onStatus(k, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
|
||||
},
|
||||
onXHR: options.onXHR,
|
||||
});
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Шаг 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// listZipFiles — рекурсивно достать из ZIP только документы (вложенные zip — разворачиваются).
|
||||
|
||||
import { parseZip } from './parse_zip.js';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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