Files
vsix-hpl-toolbox/src/tools/base64Scanner.ts
2026-05-28 00:59:37 +08:00

366 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { getToolWebviewStyles, singletonPanel } from './shared';
const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openBase64Scanner(_context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.base64Scanner', 'Base64 Scanner');
if (!isNew) return;
panel.webview.html = getHtml();
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'ready') {
const ws = vscode.workspace.workspaceFolders?.[0];
if (ws) {
const distPath = path.join(ws.uri.fsPath, 'dist');
if (fs.existsSync(distPath) && fs.statSync(distPath).isDirectory()) {
const files = await collectHtmlFiles(distPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
}
}
} else if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select Folder',
});
if (picked && picked[0]) {
const files = await collectHtmlFiles(picked[0].fsPath);
panel.webview.postMessage({ type: 'filesPicked', paths: files });
}
} else if (msg.type === 'pickFiles') {
const picked = await vscode.window.showOpenDialog({
canSelectFolders: false,
canSelectFiles: true,
canSelectMany: true,
openLabel: 'Select Files',
filters: { 'HTML': ['html', 'htm'] },
});
if (picked && picked.length) {
panel.webview.postMessage({ type: 'filesPicked', paths: picked.map(u => u.fsPath) });
}
} else if (msg.type === 'scan') {
const folder: string | undefined = msg.folder;
const files: string[] | undefined = msg.files;
let targets: string[] = [];
let baseDir = '';
if (files && files.length) {
targets = files.filter(f => fs.existsSync(f));
baseDir = targets.length ? commonBaseDir(targets) : '';
} else if (folder && fs.existsSync(folder)) {
targets = await collectHtmlFiles(folder);
baseDir = folder;
} else {
panel.webview.postMessage({ type: 'results', results: [], error: 'Pick a folder or files first' });
return;
}
const results: { file: string; ok: boolean; assets: string[] }[] = [];
for (const file of targets) {
try {
const content = fs.readFileSync(file, 'utf8');
const offenders = findNonBase64Assets(content);
const display = baseDir ? path.relative(baseDir, file) || path.basename(file) : file;
results.push({ file: display, ok: offenders.length === 0, assets: offenders });
} catch {
// skip unreadable
}
}
panel.webview.postMessage({ type: 'results', results });
}
});
}
function commonBaseDir(files: string[]): string {
if (files.length === 0) return '';
if (files.length === 1) return path.dirname(files[0]);
const split = files.map(f => path.dirname(f).split(path.sep));
const first = split[0];
let i = 0;
while (i < first.length && split.every(parts => parts[i] === first[i])) i++;
return first.slice(0, i).join(path.sep);
}
async function collectHtmlFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length) {
const dir = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue;
stack.push(full);
} else if (e.isFile() && /\.html?$/i.test(e.name)) {
out.push(full);
}
}
}
return out;
}
function findNonBase64Assets(html: string): string[] {
const markup = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
const offenders = new Set<string>();
const attrRegex = /\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')/gi;
let m: RegExpExecArray | null;
while ((m = attrRegex.exec(markup)) !== null) {
const url = (m[2] ?? m[3] ?? '').trim();
if (!url) continue;
if (isAssetReference(url) && !isBase64DataUri(url)) {
offenders.add(url);
}
}
const cssUrlRegex = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi;
while ((m = cssUrlRegex.exec(markup)) !== null) {
const url = (m[1] ?? m[2] ?? m[3] ?? '').trim();
if (!url) continue;
if (!isBase64DataUri(url)) {
offenders.add(url);
}
}
return [...offenders];
}
function isBase64DataUri(url: string): boolean {
return /^data:[^;,]*;base64,/i.test(url);
}
function isAssetReference(url: string): boolean {
if (url.startsWith('#') || url.toLowerCase().startsWith('javascript:')) return false;
if (/^(mailto:|tel:)/i.test(url)) return false;
return true;
}
function getHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
${getToolWebviewStyles()}
.asset-list { margin: 0; padding-left: 16px; }
.asset-list li + li { margin-top: 3px; }
.selected-list { margin-top: 8px; }
.remove-selected {
min-width: 28px;
width: 28px;
padding: 3px 0;
color: var(--vscode-errorForeground);
font-size: 16px;
line-height: 1;
}
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
</style>
</head>
<body>
<main class="tool-page">
<header class="tool-header">
<h2 class="tool-title">Base64 Scanner</h2>
<p class="tool-description">Scan folders or selected HTML files for asset references that are not embedded as base64 data URIs.</p>
</header>
<section class="tool-panel input-panel">
<div class="panel-header">
<h3 class="panel-title">Inputs</h3>
</div>
<div class="panel-body">
<div class="control-group">
<div class="field-row">
<input id="folder" type="hidden" />
<button id="pick" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div id="selection" class="selected-list"></div>
</div>
<div class="action-row">
<button id="scan">Scan</button>
</div>
<div id="status" class="status-panel"></div>
</div>
</section>
<div id="outputPanel" class="is-hidden">
<div id="results" class="results-panel"></div>
</div>
</main>
<script>
const vscode = acquireVsCodeApi();
vscode.postMessage({ type: 'ready' });
const folderEl = document.getElementById('folder');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const outputPanel = document.getElementById('outputPanel');
const selectionEl = document.getElementById('selection');
const PAGE_SIZE = 5;
let pickedFolder = '';
let pickedFiles = [];
let selectedPage = 0;
function basename(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));
return i >= 0 ? p.slice(i + 1) : p;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderSelection() {
const oldPager = document.getElementById('selectedPager');
if (oldPager) oldPager.remove();
const items = pickedFiles.length
? pickedFiles.map((p, i) => ({ label: basename(p), index: i, type: 'file' }))
: (pickedFolder ? [{ label: pickedFolder, index: 0, type: 'folder' }] : []);
if (!items.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(items.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const visibleItems = items.slice(selectedPage * PAGE_SIZE, selectedPage * PAGE_SIZE + PAGE_SIZE);
selectionEl.innerHTML =
'<div class="results-panel" style="margin-top:0;">' +
'<table class="data-table">' +
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead>' +
'<tbody>' +
visibleItems.map(item =>
'<tr><td class="mono wrap">' + escapeHtml(item.label) + '</td>' +
'<td><button class="remove-selected danger" data-index="' + item.index + '" data-type="' + item.type + '" title="Remove">×</button></td></tr>'
).join('') +
'</tbody></table>' +
'</div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.type === 'folder') {
pickedFolder = '';
folderEl.value = '';
} else {
pickedFiles.splice(Number(btn.dataset.index), 1);
}
renderSelection();
});
});
if (items.length > PAGE_SIZE) {
const pager = document.createElement('div');
pager.id = 'selectedPager';
pager.className = 'pager';
pager.innerHTML =
'<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
selectionEl.appendChild(pager);
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
}
}
renderSelection();
document.getElementById('pick').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' });
});
document.getElementById('pickFiles').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFiles' });
});
document.getElementById('clear').addEventListener('click', () => {
folderEl.value = '';
pickedFiles = [];
pickedFolder = '';
selectedPage = 0;
renderSelection();
statusEl.textContent = '';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
});
document.getElementById('scan').addEventListener('click', () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
if (pickedFiles.length) {
vscode.postMessage({ type: 'scan', files: pickedFiles });
} else {
const folder = folderEl.value.trim();
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
vscode.postMessage({ type: 'scan', folder });
}
});
window.addEventListener('message', (event) => {
const msg = event.data;
if (msg.type === 'folderPicked') {
folderEl.value = msg.path;
pickedFolder = msg.path;
pickedFiles = [];
selectedPage = 0;
renderSelection();
} else if (msg.type === 'filesPicked') {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((msg.paths || []).filter(p => !existing.has(p)));
folderEl.value = '';
pickedFolder = '';
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
} else if (msg.type === 'results') {
if (msg.error) {
statusEl.textContent = 'Error: ' + msg.error;
outputPanel.classList.add('is-hidden');
return;
}
const total = msg.results.length;
const flagged = msg.results.filter(r => !r.ok).length;
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) {
resultsEl.innerHTML = '';
outputPanel.classList.add('is-hidden');
return;
}
outputPanel.classList.remove('is-hidden');
resultsEl.innerHTML =
'<table class="data-table">' +
'<thead><tr><th style="width:90px;">Status</th><th>File</th><th style="width:90px;">Issues</th><th>Non-base64 assets</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
const tbody = resultsEl.querySelector('tbody');
for (const r of msg.results) {
const row = document.createElement('tr');
const status = document.createElement('td');
status.innerHTML = r.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>';
const name = document.createElement('td');
name.className = 'mono wrap';
name.textContent = r.file;
const count = document.createElement('td');
count.textContent = String(r.assets.length);
const assets = document.createElement('td');
assets.className = 'wrap';
if (!r.ok) {
const list = document.createElement('ul');
list.className = 'asset-list mono';
r.assets.forEach(asset => {
const item = document.createElement('li');
item.textContent = asset;
list.appendChild(item);
});
assets.appendChild(list);
} else {
assets.innerHTML = '<span class="muted">None</span>';
}
row.append(status, name, count, assets);
tbody.appendChild(row);
}
}
});
</script>
</body>
</html>`;
}