package main import ( "encoding/json" "fmt" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) const SharedCSS = ` :root { color-scheme: dark; --tool-gap-sm: 8px; --tool-gap-md: 12px; --tool-gap-lg: 16px; --tool-radius: 5px; --tool-border: #333; --tool-panel: #252526; } * { box-sizing: border-box; } html { min-height: 100%; } body { min-height: 100vh; display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #1e1e1e; color: #ddd; margin: 0; padding: 0; } .topbar { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: #252526; border-bottom: 1px solid #333; font-size: 13px; } .topbar .nav-link { background: transparent; color: #ddd; border: 0; padding: 5px 10px; border-radius: 4px; cursor: pointer; font: inherit; } .topbar .nav-link:hover { background: #2a2d2e; } .topbar .nav-link.active { background: #094771; color: #fff; } .beta-pill { margin-left: 4px; padding: 1px 4px; border: 1px solid #555; border-radius: 3px; font-size: 10px; opacity: 0.7; text-transform: uppercase; } .topbar-spacer { margin-left: auto; } .content { flex: 1; width: 100%; padding: 18px; max-width: 1100px; margin: 0 auto; } .app-footer { width: 100%; max-width: 1100px; margin: auto auto 0; padding: 8px 18px 14px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; color: #a7a7a7; font-size: 10px; border-top: 1px solid #333; } .beta-tools-toggle { justify-self: start; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; text-align: left; } .beta-tools-toggle:hover { background: transparent; opacity: 1; text-decoration: underline; } .app-footer-version { opacity: 0.75; text-align: center; white-space: nowrap; } .app-footer-link { justify-self: end; padding: 0; background: transparent; color: #a7a7a7; border: 0; cursor: pointer; font: inherit; opacity: 0.72; } .app-footer-link:hover { background: transparent; opacity: 1; text-decoration: underline; } h2 { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; } .tool-header { margin-bottom: var(--tool-gap-lg); } .tool-title { margin: 0; font-size: 18px; line-height: 1.25; font-weight: 600; } .tool-description, .section-description, .muted { color: #a7a7a7; } .tool-description { margin: 5px 0 0; max-width: 780px; font-size: 12px; line-height: 1.45; } .tool-panel { margin-bottom: var(--tool-gap-lg); border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); overflow: hidden; } .panel-header { padding: 10px 12px; border-bottom: 1px solid var(--tool-border); background: var(--tool-panel); } .panel-title { margin: 0; color: #ddd; font-size: 12px; font-weight: 700; letter-spacing: 0.45px; text-transform: uppercase; } .panel-body { padding: 12px; } .control-group + .control-group { margin-top: var(--tool-gap-md); } .control-label { margin: 0 0 6px; color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; } .file-row, .field-row, .action-row { display: flex; align-items: center; gap: var(--tool-gap-sm); flex-wrap: wrap; } .action-row { margin-top: var(--tool-gap-md); } input[type=text], input[type=date], select, textarea { box-sizing: border-box; padding: 6px 8px; background: #3c3c3c; color: #ddd; border: 1px solid #3c3c3c; border-radius: 2px; font-family: inherit; font-size: 13px; } input[type=text]:focus, input[type=date]:focus, select:focus, textarea:focus { outline: 1px solid #007fd4; border-color: #007fd4; } button { background: #0e639c; color: #fff; border: none; padding: 6px 14px; border-radius: 2px; cursor: pointer; font-size: 13px; } button:hover { background: #1177bb; } button.secondary { background: #3a3d41; color: #ddd; } button.secondary:hover { background: #45494e; } button.danger { background: transparent; color: #f48771; border: 1px solid #444; } button.danger:hover { background: rgba(244,135,113,0.14); } button:disabled { opacity: 0.5; cursor: not-allowed; } .status-panel, .results-panel, .result-card { border: 1px solid var(--tool-border); border-radius: var(--tool-radius); background: rgba(128,128,128,0.08); } .status-panel { min-height: 30px; margin-top: var(--tool-gap-md); padding: 8px 10px; white-space: pre-wrap; } .status-panel.is-busy { display: flex; align-items: center; gap: 8px; } .status-panel.is-busy::before { content: ""; width: 12px; height: 12px; flex: 0 0 auto; border: 2px solid rgba(167,167,167,0.35); border-top-color: #ddd; border-radius: 50%; animation: status-spin 800ms linear infinite; } @keyframes status-spin { to { transform: rotate(360deg); } } .status-panel:empty { display: none; } .results-panel { margin-top: var(--tool-gap-md); overflow: auto; } .results-panel:empty { display: none; } .result-card { padding: 10px; word-break: break-word; } .data-table { width: 100%; border-collapse: collapse; table-layout: fixed; } .data-table th, .data-table td { padding: 8px 10px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--tool-border); } .data-table th { color: #a7a7a7; font-size: 11px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; background: var(--tool-panel); } .data-table tr:last-child td { border-bottom: none; } .mono { font-family: Consolas, "Courier New", monospace; font-size: 12px; } .wrap { overflow-wrap: anywhere; word-break: break-word; } .file-name { color: #a7a7a7; font-size: 12px; overflow-wrap: anywhere; } .drop-zone { margin-top: 8px; min-height: 44px; display: grid; justify-items: center; gap: 8px; padding: 10px 12px; border: 1px dashed #444; border-radius: var(--tool-radius); color: #a7a7a7; background: rgba(128,128,128,0.08); font-size: 12px; text-align: center; transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; } .drop-zone .file-row { justify-content: center; } .drop-zone-text { color: #a7a7a7; font-size: 12px; } .drop-zone.is-dragover { border-color: #007fd4; color: #ddd; background: rgba(0,127,212,0.12); } .selected-list, .selected-files { margin-top: 8px; } .remove-selected, .remove-btn { min-width: 28px; width: 28px; padding: 3px 0; color: #f48771; font-size: 16px; line-height: 1; } .pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; } .badge { display: inline-flex; align-items: center; min-height: 18px; padding: 1px 6px; border-radius: 999px; border: 1px solid #444; font-size: 11px; line-height: 1.2; white-space: nowrap; } .badge.ok { color: #89d185; background: rgba(137,209,133,0.12); } .badge.err { color: #f48771; background: rgba(244,135,113,0.14); } .badge.warn { color: #ffd580; background: rgba(210,153,34,0.16); } .is-hidden { display: none !important; } .err { color: #f48771; white-space: pre-wrap; } .ok { color: #89d185; } .hint { font-size: 12px; opacity: 0.7; } ` const SharedDropZoneScript = ` function extractDroppedPaths(dataTransfer) { const paths = []; const add = (value) => { if (!value) return; const trimmed = String(value).trim(); if (trimmed && !paths.includes(trimmed)) paths.push(trimmed); }; const addPathText = (text) => { const value = String(text || ''); if (!value) return; const uriMatches = value.match(/file:\/\/(?:\/[A-Za-z]:)?[^\\r\\n\\0]+/gi) || []; uriMatches.forEach(add); const quotedMatches = value.match(/"([^"]+)"|'([^']+)'/g) || []; quotedMatches.forEach(match => add(match.replace(/^["']|["']$/g, ''))); value.split(/[\r\n\0]+/).forEach(line => { let rest = line.trim(); if (!rest || rest.startsWith('#')) return; while (rest.length) { const driveMatches = [...rest.matchAll(/[A-Za-z]:\\/g)].map(m => m.index).filter(i => typeof i === 'number'); if (driveMatches.length <= 1) { add(rest); break; } const next = driveMatches[1]; add(rest.slice(0, next).trim()); rest = rest.slice(next).trim(); } }); }; const addLines = (text) => { addPathText(text); }; try { addLines(dataTransfer.getData('text/uri-list')); } catch {} try { addLines(dataTransfer.getData('text/plain')); } catch {} for (const file of Array.from(dataTransfer.files || [])) { add(file.path); } return paths; } async function extractDroppedHtmlFiles(dataTransfer) { const files = []; const seen = new Set(); const droppedFiles = Array.from(dataTransfer.files || []); const entries = []; const handlePromises = []; for (const item of Array.from(dataTransfer.items || [])) { if (typeof item.getAsFileSystemHandle === 'function') { handlePromises.push(item.getAsFileSystemHandle().catch(() => null)); } let entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; if (!entry && typeof item.getAsEntry === 'function') entry = item.getAsEntry(); if (entry) entries.push(entry); } const addFile = async (file, name) => { const fileName = name || file.name || 'dropped.html'; if (!/\.html?$/i.test(fileName)) return; const key = fileName + ':' + file.size + ':' + file.lastModified; if (seen.has(key)) return; seen.add(key); files.push({ name: fileName, content: await file.text() }); }; const readEntry = async (entry, prefix) => { if (!entry) return; if (entry.isFile) { await new Promise((resolve) => { entry.file(async (file) => { await addFile(file, (prefix || '') + file.name); resolve(); }, () => resolve()); }); return; } if (entry.isDirectory) { const reader = entry.createReader(); while (true) { const entries = await new Promise(resolve => reader.readEntries(resolve, () => resolve([]))); if (!entries.length) break; for (const child of entries) await readEntry(child, (prefix || '') + entry.name + '/'); } } }; const readHandle = async (handle, prefix) => { if (!handle) return; if (handle.kind === 'file') { try { const file = await handle.getFile(); await addFile(file, (prefix || '') + file.name); } catch {} return; } if (handle.kind === 'directory') { try { for await (const child of handle.values()) { await readHandle(child, (prefix || '') + handle.name + '/'); } } catch {} } }; for (const file of droppedFiles) await addFile(file, file.name); for (const entry of entries) await readEntry(entry, ''); for (const handle of await Promise.all(handlePromises)) await readHandle(handle, ''); return files; } function setupDropZone(id, statusElement, onResolved) { const zone = document.getElementById(id); if (!zone) return; const fallback = 'This view could not read dropped file paths. Use Select File(s) or Select Folder instead.'; const setMessage = (text) => { if (statusElement) statusElement.textContent = text || ''; }; ['dragenter', 'dragover'].forEach(type => { zone.addEventListener(type, (event) => { if (event.target && event.target.closest && event.target.closest('button, input')) return; event.preventDefault(); event.stopPropagation(); zone.classList.add('is-dragover'); }); }); ['dragleave', 'dragend'].forEach(type => { zone.addEventListener(type, (event) => { event.preventDefault(); event.stopPropagation(); zone.classList.remove('is-dragover'); }); }); zone.addEventListener('drop', async (event) => { event.preventDefault(); event.stopPropagation(); zone.classList.remove('is-dragover'); const paths = extractDroppedPaths(event.dataTransfer); const files = await extractDroppedHtmlFiles(event.dataTransfer); if (!paths.length && !files.length) { setMessage(fallback); return; } setMessage(''); try { const r = await fetch('/api/drop/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths, files }), }); const j = await r.json(); if (typeof onResolved === 'function') onResolved(j); } catch (e) { setMessage('Could not read dropped items: ' + (e.message || e)); } }); } ` type navItem struct { Path string Label string Description string Beta bool } var navItems = []navItem{ {Path: "/", Label: "Home"}, {Path: "/plec", Label: "PLEC Upload", Description: "Upload HTML to the internal PLEC server"}, {Path: "/applovin", Label: "AppLovin Playable Preview", Description: "Upload to p.applov.in (QR preview)"}, {Path: "/base64", Label: "Base64 Scanner", Description: "Find non-base64 assets in HTML"}, {Path: "/mobile", Label: "Send To Mobile", Description: "Share HTML to a phone via LAN + QR"}, {Path: "/mraid", Label: "MRAID Checker", Description: "Check MRAID requirements and best practices"}, {Path: "/playworks", Label: "Playworks Converter", Description: "Convert Playworks HTML to per-network variants", Beta: true}, } func visibleNavItems(betaToolsEnabled bool) []navItem { out := make([]navItem, 0, len(navItems)) for _, n := range navItems { if n.Beta && !betaToolsEnabled { continue } out = append(out, n) } return sortNavItemsForDisplay(out) } func sortNavItemsForDisplay(items []navItem) []navItem { out := append([]navItem(nil), items...) for i := 0; i < len(out); i++ { for j := i + 1; j < len(out); j++ { if out[i].Beta && !out[j].Beta { out[i], out[j] = out[j], out[i] } } } return out } func betaToolCount() int { count := 0 for _, n := range navItems { if n.Beta { count++ } } return count } func Page(activePath, title, body string) string { cfg := LoadConfig() betaToolsEnabled := cfg.BetaToolsEnabled var tabs strings.Builder for _, n := range visibleNavItems(betaToolsEnabled) { cls := "" if n.Path == activePath { cls = " active" } label := n.Label if n.Beta { label += `Beta` } tabs.WriteString(``) } betaButtonLabel := "Show beta" if betaToolsEnabled { betaButtonLabel = "Hide beta" } return ` ` + title + ` — HPL Toolbox
` + tabs.String() + `
` + body + `
` } func boolAttr(v bool) string { if v { return "true" } return "false" } func DropResolveEndpoint(w http.ResponseWriter, r *http.Request) { type droppedFile struct { Name string `json:"name"` Content string `json:"content"` } var req struct { Paths []string `json:"paths"` Files []droppedFile `json:"files"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"files": []any{}, "paths": []string{}, "skipped": 0, "error": err.Error()}) return } seen := map[string]bool{} var outPaths []string skipped := 0 addFile := func(p string) { if !isHTMLPath(p) { skipped++ return } key := strings.ToLower(filepath.Clean(p)) if seen[key] { return } seen[key] = true outPaths = append(outPaths, p) } for _, raw := range req.Paths { p := normalizeDroppedPath(raw) if p == "" { skipped++ continue } info, err := os.Stat(p) if err != nil { skipped++ continue } if info.IsDir() { for _, filePath := range collectHTMLFiles(p) { addFile(filePath) } continue } if info.Mode().IsRegular() { addFile(p) continue } skipped++ } if len(req.Files) > 0 { cleanupStaleStandaloneDropDirs() dir, err := os.MkdirTemp("", "hpltoolbox-drop-") if err != nil { skipped += len(req.Files) } else { for _, file := range req.Files { if !isHTMLPath(file.Name) { skipped++ continue } name := safeStandaloneDropName(file.Name) target := filepath.Join(dir, name) ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) for i := 1; fileExists(target); i++ { target = filepath.Join(dir, fmt.Sprintf("%s_%d%s", base, i, ext)) } if err := os.WriteFile(target, []byte(file.Content), 0644); err != nil { skipped++ continue } addFile(target) } } } files := make([]map[string]string, 0, len(outPaths)) for _, p := range outPaths { files = append(files, map[string]string{"path": p, "name": filepath.Base(p)}) } writeJSON(w, map[string]any{"files": files, "paths": outPaths, "skipped": skipped}) } func safeStandaloneDropName(name string) string { base := filepath.Base(name) base = strings.Map(func(r rune) rune { switch r { case '<', '>', ':', '"', '/', '\\', '|', '?', '*': return '_' default: if r < 32 { return '_' } return r } }, base) base = strings.TrimSpace(base) if base == "" { return "dropped.html" } return base } func cleanupStaleStandaloneDropDirs() { root := os.TempDir() entries, err := os.ReadDir(root) if err != nil { return } cutoff := time.Now().Add(-24 * time.Hour) for _, entry := range entries { if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "hpltoolbox-drop-") { continue } dir := filepath.Join(root, entry.Name()) info, err := entry.Info() if err == nil && info.ModTime().Before(cutoff) { _ = os.RemoveAll(dir) } } } func normalizeDroppedPath(value string) string { result := strings.Trim(strings.TrimSpace(value), `"'`) if strings.HasPrefix(strings.ToLower(result), "file://") { result = strings.TrimPrefix(result, "file://") if decoded, err := url.PathUnescape(result); err == nil { result = decoded } if len(result) >= 3 && result[0] == '/' && result[2] == ':' { result = result[1:] } result = filepath.FromSlash(result) } return result } func isHTMLPath(p string) bool { ext := strings.ToLower(filepath.Ext(p)) return ext == ".html" || ext == ".htm" } func BetaToolsEndpoint(w http.ResponseWriter, r *http.Request) { var req struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) return } cfg := LoadConfig() cfg.BetaToolsEnabled = req.Enabled if err := SaveConfig(cfg); err != nil { writeJSON(w, map[string]any{"ok": false, "error": err.Error()}) return } writeJSON(w, map[string]any{"ok": true}) }