feat: integrate upload-platform v0.2.2 into drhider (v0.0.78)
Deploy drhider / validate (push) Canceled after 0s
Deploy drhider / validate (push) Canceled after 0s
- Update upload/ module to v0.2.2 with modular Layer 1 (FilePicker) and Layer 2 (Streaming transit upload) - Replace legacy manual file table in site/templates/index.html with FilePicker.initFilePicker - Wire uploadViaVM with per-file status updates and abort signal support - Add dist bundles to dist/ and site/static/dist/ with routes in site/routes/main_bp.py - Add test_hardening.py and test_safe_name.py from upload-platform - Bump version to 0.0.78 in site/app.py - Document integration plan and report in History/upload-integration/
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user