Files
vsix-hpl-toolbox/standalone/base64.go

378 lines
12 KiB
Go
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.
package main
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
func Base64Page(w http.ResponseWriter, r *http.Request) {
body := `
<header class="tool-header">
<h2 class="tool-title">Base64 Scanner</h2>
<p class="tool-description">Scan selected HTML files for asset references that are not embedded as base64 data URIs.</p>
</header>
<section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
<div class="panel-body">
<div class="control-group">
<div id="dropZone" class="drop-zone">
<div class="file-row">
<button id="pickFolder" class="secondary">Select Folder</button>
<button id="pickFiles" class="secondary">Select File(s)</button>
<button id="clear" class="secondary">Clear</button>
</div>
<div class="drop-zone-text">or drop HTML files or folders here</div>
</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="results" class="results-panel"></div>
<style>
.asset-list { margin:0; padding-left:16px; }
.asset-list li + li { margin-top:3px; }
</style>
<script>
const PAGE_SIZE = 5;
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('results');
const selectionEl = document.getElementById('selection');
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();
if (!pickedFiles.length) {
selectionEl.innerHTML = '<span class="file-name">(no files selected)</span>';
return;
}
const maxPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
selectedPage = Math.min(selectedPage, maxPage);
const start = selectedPage * PAGE_SIZE;
const visible = pickedFiles.slice(start, start + 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>' +
visible.map((p, offset) => {
const i = start + offset;
return '<tr><td class="mono wrap">' + escapeHtml(basename(p)) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
}).join('') + '</tbody></table></div>';
selectionEl.querySelectorAll('.remove-selected').forEach(btn => {
btn.addEventListener('click', () => { pickedFiles.splice(Number(btn.dataset.index), 1); renderSelection(); });
});
if (pickedFiles.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(); });
}
}
function addPaths(paths) {
const existing = new Set(pickedFiles);
pickedFiles = pickedFiles.concat((paths || []).filter(p => !existing.has(p)));
selectedPage = Math.max(0, Math.ceil(pickedFiles.length / PAGE_SIZE) - 1);
renderSelection();
}
async function readJSONStream(response, handle) {
if (!response.ok || !response.body) throw new Error('Request failed.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) handle(JSON.parse(line));
}
}
if (buffer.trim()) handle(JSON.parse(buffer));
}
renderSelection();
setupDropZone('dropZone', statusEl, (j) => {
addPaths(j.paths || []);
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
});
document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
const j = await r.json();
addPaths(j.paths || []);
});
document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
const j = await r.json();
addPaths(j.paths || []);
});
document.getElementById('clear').addEventListener('click', () => {
pickedFiles = [];
selectedPage = 0;
statusEl.textContent = '';
statusEl.classList.remove('is-busy');
resultsEl.innerHTML = '';
renderSelection();
});
document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...';
statusEl.classList.add('is-busy');
resultsEl.innerHTML = '';
if (!pickedFiles.length) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Pick files first.'; return; }
const payload = { files: pickedFiles };
let j = null;
try {
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
await readJSONStream(r, (msg) => {
if (msg.type === 'status') {
statusEl.textContent = msg.message;
statusEl.classList.add('is-busy');
} else if (msg.type === 'done') {
j = msg;
} else if (msg.type === 'error') {
j = { error: msg.message };
}
});
} catch (e) {
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Error: ' + (e.message || e);
return;
}
if (!j) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: No response.'; return; }
if (j.error) { statusEl.classList.remove('is-busy'); statusEl.textContent = 'Error: ' + j.error; return; }
const total = j.results.length;
const flagged = j.results.filter(r => !r.ok).length;
statusEl.classList.remove('is-busy');
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.';
if (!total) { resultsEl.innerHTML = ''; return; }
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 rr of j.results) {
const row = document.createElement('tr');
const assets = rr.ok ? '<span class="muted">None</span>' : '<ul class="asset-list mono">' + rr.assets.map(a => '<li>' + escapeHtml(a) + '</li>').join('') + '</ul>';
row.innerHTML = '<td>' + (rr.ok ? '<span class="badge ok">OK</span>' : '<span class="badge err">Review</span>') + '</td>' +
'<td class="mono wrap">' + escapeHtml(rr.file) + '</td><td>' + rr.assets.length + '</td><td class="wrap">' + assets + '</td>';
tbody.appendChild(row);
}
});
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/base64", "Base64 Scanner", body)))
}
func Base64PickFolder(w http.ResponseWriter, r *http.Request) {
p := PickFolder("Select folder to scan", "")
if p == "" {
writeJSON(w, map[string]any{"paths": []string{}})
return
}
writeJSON(w, map[string]any{"paths": collectHTMLFiles(p)})
}
func Base64PickFiles(w http.ResponseWriter, r *http.Request) {
paths := PickFiles(
"Select HTML files",
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
true, "",
)
writeJSON(w, map[string]any{"paths": paths})
}
type scanResult struct {
File string `json:"file"`
OK bool `json:"ok"`
Assets []string `json:"assets"`
}
func Base64Scan(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w)
var req struct {
Folder string `json:"folder"`
Files []string `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
var targets []string
var baseDir string
if len(req.Files) > 0 {
for _, f := range req.Files {
if fileExists(f) {
targets = append(targets, f)
}
}
if len(targets) > 0 {
baseDir = commonBaseDir(targets)
}
} else if req.Folder != "" && fileExists(req.Folder) {
targets = collectHTMLFiles(req.Folder)
baseDir = req.Folder
} else {
send(map[string]any{"type": "error", "message": "Pick a folder or files first"})
return
}
results := []scanResult{}
for i, file := range targets {
send(map[string]any{"type": "status", "message": fmt.Sprintf("Scanning %d/%d %s", i+1, len(targets), filepath.Base(file))})
data, err := os.ReadFile(file)
if err != nil {
continue
}
offenders := findNonBase64Assets(string(data))
display := file
if baseDir != "" {
rel, err := filepath.Rel(baseDir, file)
if err == nil && rel != "" {
display = rel
} else {
display = filepath.Base(file)
}
}
results = append(results, scanResult{
File: display,
OK: len(offenders) == 0,
Assets: offenders,
})
}
send(map[string]any{"type": "done", "results": results})
}
func commonBaseDir(files []string) string {
if len(files) == 0 {
return ""
}
if len(files) == 1 {
return filepath.Dir(files[0])
}
split := make([][]string, len(files))
for i, f := range files {
split[i] = strings.Split(filepath.Dir(f), string(filepath.Separator))
}
first := split[0]
i := 0
for i < len(first) {
match := true
for _, parts := range split {
if i >= len(parts) || parts[i] != first[i] {
match = false
break
}
}
if !match {
break
}
i++
}
return strings.Join(first[:i], string(filepath.Separator))
}
func collectHTMLFiles(root string) []string {
var out []string
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
name := d.Name()
if name == "node_modules" || name == ".git" {
return filepath.SkipDir
}
return nil
}
if htmlRx.MatchString(d.Name()) {
out = append(out, path)
}
return nil
})
return out
}
var (
scriptBlockRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script>`)
attrRx = regexp.MustCompile(`(?i)\b(?:src|href|poster|data-src)\s*=\s*("([^"]*)"|'([^']*)')`)
cssUrlRx = regexp.MustCompile(`(?i)url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)`)
base64DataRx = regexp.MustCompile(`(?i)^data:[^;,]*;base64,`)
mailTelRx = regexp.MustCompile(`(?i)^(mailto:|tel:)`)
)
func findNonBase64Assets(htmlSrc string) []string {
markup := scriptBlockRx.ReplaceAllString(htmlSrc, "")
seen := map[string]bool{}
var out []string
add := func(u string) {
if u == "" || seen[u] {
return
}
seen[u] = true
out = append(out, u)
}
for _, m := range attrRx.FindAllStringSubmatch(markup, -1) {
url := m[2]
if url == "" {
url = m[3]
}
url = strings.TrimSpace(url)
if url == "" {
continue
}
if isAssetReference(url) && !base64DataRx.MatchString(url) {
add(url)
}
}
for _, m := range cssUrlRx.FindAllStringSubmatch(markup, -1) {
url := m[1]
if url == "" {
url = m[2]
}
if url == "" {
url = m[3]
}
url = strings.TrimSpace(url)
if url == "" {
continue
}
if !base64DataRx.MatchString(url) {
add(url)
}
}
if out == nil {
out = []string{}
}
return out
}
func isAssetReference(url string) bool {
if strings.HasPrefix(url, "#") || strings.HasPrefix(strings.ToLower(url), "javascript:") {
return false
}
if mailTelRx.MatchString(url) {
return false
}
return true
}