340 lines
11 KiB
Go
340 lines
11 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||
}
|
||
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 };
|
||
const r = await fetch('/api/base64/scan', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
||
const j = await r.json();
|
||
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) {
|
||
var req struct {
|
||
Folder string `json:"folder"`
|
||
Files []string `json:"files"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": 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 {
|
||
writeJSON(w, map[string]any{"results": []scanResult{}, "error": "Pick a folder or files first"})
|
||
return
|
||
}
|
||
|
||
results := []scanResult{}
|
||
for _, file := range targets {
|
||
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,
|
||
})
|
||
}
|
||
writeJSON(w, map[string]any{"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
|
||
}
|