Add standalone Go build alongside VSCode extension
Ports all five tools to a single 7.5 MB Windows exe with an embedded WebView2 window, file-based config, single-instance focus, and clean shutdown. Adds build.ps1 to build both targets from one command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
289
standalone/base64.go
Normal file
289
standalone/base64.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Base64Page(w http.ResponseWriter, r *http.Request) {
|
||||
body := `
|
||||
<h2>Base64 Asset Scanner</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||||
<input id="folder" type="text" placeholder="Folder to scan recursively..." style="flex:1;" />
|
||||
<button id="pickFolder">Browse Folder...</button>
|
||||
<button id="pickFiles">Pick Files...</button>
|
||||
<button id="scan">Scan</button>
|
||||
</div>
|
||||
<div id="selection" class="hint"></div>
|
||||
<div id="status" style="margin-top:8px;"></div>
|
||||
<div id="results" style="margin-top:8px;"></div>
|
||||
|
||||
<style>
|
||||
.row-result { display:flex; gap:8px; padding:2px 0; font-family:Consolas, monospace; font-size:12px; }
|
||||
.mark { width:14px; flex-shrink:0; font-weight:bold; }
|
||||
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
|
||||
.file-name { word-break:break-all; }
|
||||
.assets { opacity:0.7; margin-left:6px; word-break:break-all; }
|
||||
.summary { opacity:0.8; margin-bottom:6px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const folderEl = document.getElementById('folder');
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const selectionEl = document.getElementById('selection');
|
||||
let pickedFiles = [];
|
||||
function clearFiles(){ pickedFiles = []; selectionEl.textContent = ''; }
|
||||
folderEl.addEventListener('input', clearFiles);
|
||||
|
||||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFolder', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.path) { folderEl.value = j.path; clearFiles(); }
|
||||
});
|
||||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||||
const r = await fetch('/api/base64/pickFiles', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.paths && j.paths.length) {
|
||||
pickedFiles = j.paths;
|
||||
folderEl.value = '';
|
||||
selectionEl.textContent = pickedFiles.length + ' file(s) selected';
|
||||
}
|
||||
});
|
||||
document.getElementById('scan').addEventListener('click', async () => {
|
||||
statusEl.textContent = 'Scanning...';
|
||||
resultsEl.innerHTML = '';
|
||||
let payload;
|
||||
if (pickedFiles.length) payload = { files: pickedFiles };
|
||||
else {
|
||||
const folder = folderEl.value.trim();
|
||||
if (!folder) { statusEl.textContent = 'Pick a folder or files first.'; return; }
|
||||
payload = { folder };
|
||||
}
|
||||
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.textContent = 'Error: ' + j.error; return; }
|
||||
const total = j.results.length;
|
||||
const flagged = j.results.filter(r => !r.ok).length;
|
||||
statusEl.innerHTML = '<div class="summary">Scanned ' + total + ' HTML file(s). ' + flagged + ' contain non-base64 assets.</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
for (const rr of j.results) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row-result';
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'mark ' + (rr.ok ? 'ok' : 'bad');
|
||||
mark.textContent = rr.ok ? '✓' : '✗';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'file-name';
|
||||
name.textContent = rr.file;
|
||||
row.appendChild(mark); row.appendChild(name);
|
||||
if (!rr.ok) {
|
||||
const a = document.createElement('span');
|
||||
a.className = 'assets';
|
||||
a.textContent = '— ' + rr.assets.join(', ');
|
||||
row.appendChild(a);
|
||||
}
|
||||
resultsEl.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", "")
|
||||
writeJSON(w, map[string]any{"path": 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
|
||||
}
|
||||
Reference in New Issue
Block a user