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/
125 lines
6.1 KiB
JavaScript
125 lines
6.1 KiB
JavaScript
// Построить дерево ZIP с файлами только разрешённых расширений.
|
||
import { unzipSync } from 'fflate';
|
||
import { rebaseTree } from '../table/rebase_tree.js';
|
||
|
||
const DEFAULT_LIMITS = {
|
||
maxEntries: 1000,
|
||
maxTotalBytes: 100 * 1024 * 1024,
|
||
maxEntryBytes: 50 * 1024 * 1024,
|
||
maxDepth: 20,
|
||
};
|
||
|
||
/** Возвращает 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 });
|
||
} |