mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
xlsx files were download-only. Add a read-only, virtualized preview (sheet tabs, number formats, merges, theme colours) parsed entirely in a browser Web Worker with exceljs and fflate, loaded only when a spreadsheet is opened. The workbook is checked against ZIP-bomb, entry and cell limits before exceljs loads; cell text is written with textContent, formulas are never evaluated and nothing referenced by the workbook is fetched. On the server xlsx only joins the existing allowlist and classification, with a 10 MB cap on ?preview=true. xls and ods stay download-only.
105 lines
4.0 KiB
JavaScript
105 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const publicRoot = resolve(repoRoot, 'src/web/public');
|
|
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
|
|
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
|
|
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
|
|
// They load only inside the spreadsheet worker, but a dependency bump that
|
|
// balloons them should be a deliberate decision, not a silent one.
|
|
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
|
|
|
|
function collectTextAssets(dir) {
|
|
const files = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const fullPath = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...collectTextAssets(fullPath));
|
|
continue;
|
|
}
|
|
if (checkedExtensions.has(extname(entry.name))) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function findNullByte(buffer) {
|
|
for (let i = 0; i < buffer.length; i += 1) {
|
|
if (buffer[i] === 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
const files = collectTextAssets(publicRoot);
|
|
const failures = [];
|
|
|
|
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
|
|
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
|
|
// the content hash of everything the worker loads, or a deploy can pair a new
|
|
// worker with a stale cached core/vendor file (static assets are cached 1y).
|
|
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
|
|
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
|
|
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
|
|
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
|
|
|
|
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
|
|
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
|
|
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
|
|
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
|
|
}
|
|
|
|
const expectedVersion = createHash('sha256')
|
|
.update(readFileSync(spreadsheetWorker))
|
|
.update(readFileSync(spreadsheetCore))
|
|
.update(readFileSync(spreadsheetVendors[0]))
|
|
.update(readFileSync(spreadsheetVendors[1]))
|
|
.digest('hex')
|
|
.slice(0, 12);
|
|
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
|
|
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
|
|
if (actualVersion !== expectedVersion) {
|
|
failures.push(
|
|
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
|
|
);
|
|
}
|
|
} else {
|
|
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
|
|
}
|
|
|
|
for (const file of files) {
|
|
const rel = relative(repoRoot, file);
|
|
const data = readFileSync(file);
|
|
const nullByteIndex = findNullByte(data);
|
|
if (nullByteIndex !== -1) {
|
|
failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`);
|
|
}
|
|
|
|
if (extname(file) === '.js') {
|
|
try {
|
|
execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' });
|
|
} catch (err) {
|
|
failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' });
|
|
} catch (err) {
|
|
failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`);
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error(failures.join('\n\n'));
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Public asset checks passed (${files.length} files).`);
|