Files
vsix-hpl-toolbox/standalone/mraid.go
2026-05-28 01:25:53 +08:00

464 lines
19 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"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
type mraidIssue struct {
ID string `json:"id"`
Severity string `json:"severity"`
Title string `json:"title"`
Detail string `json:"detail"`
Line int `json:"line,omitempty"`
Reference string `json:"reference"`
}
type mraidResult struct {
File string `json:"file"`
OK bool `json:"ok"`
Issues []mraidIssue `json:"issues"`
}
type mraidRule struct {
ID string
Severity string
Title string
Detail string
Reference string
Test func(mraidContext, mraidRule) *mraidIssue
}
type mraidContext struct {
HTML string
ScriptText string
MraidCallLines map[string]int
}
func MraidPage(w http.ResponseWriter, r *http.Request) {
body := `
<header class="tool-header">
<h2 class="tool-title">MRAID Checker</h2>
<p class="tool-description">Static checks for AppLovin, ironSource, and Unity folder HTML builds based on MRAID 3.0 requirements and best practices from mraidDocuments/.</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 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 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>
.row-result { padding:8px 0; border-bottom:1px solid #333; }
.file-head { display:flex; gap:8px; align-items:baseline; font-family:Consolas, monospace; font-size:12px; }
.mark { width:20px; flex-shrink:0; font-weight:bold; }
.mark.ok { color:#3fb950; } .mark.bad { color:#f85149; }
.file-name { word-break:break-all; }
.counts { opacity:0.7; margin-left:auto; white-space:nowrap; }
.issues { margin:6px 0 0 28px; padding:0; }
.issue { list-style:none; margin:6px 0; }
.badge { display:inline-block; min-width:82px; margin-right:6px; padding:1px 5px; border-radius:3px; font-size:11px; text-align:center; }
.requirement { color:#ffb4ad; background:rgba(248,81,73,0.18); }
.best-practice { color:#ffd580; background:rgba(210,153,34,0.18); }
.issue-title { font-weight:600; }
.issue-detail { opacity:0.72; margin-top:2px; }
.summary { opacity:0.85; 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');
const PAGE_SIZE = 5;
let pickedFiles = [];
let selectedPage = 0;
function escapeText(value){ return String(value).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); }
function basename(p) { const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); return i >= 0 ? p.slice(i + 1) : p; }
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">' + escapeText(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();
document.getElementById('pickFolder').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFolder', { method: 'POST' });
const j = await r.json();
addPaths(j.paths || []);
});
document.getElementById('pickFiles').addEventListener('click', async () => {
const r = await fetch('/api/mraid/pickFiles', { method: 'POST' });
const j = await r.json();
addPaths(j.paths || []);
});
document.getElementById('clear').addEventListener('click', () => {
pickedFiles = [];
selectedPage = 0;
statusEl.textContent = '';
resultsEl.innerHTML = '';
renderSelection();
});
document.getElementById('scan').addEventListener('click', async () => {
statusEl.textContent = 'Scanning...';
resultsEl.innerHTML = '';
if (!pickedFiles.length) { statusEl.textContent = 'Pick files first.'; return; }
const payload = { files: pickedFiles };
const r = await fetch('/api/mraid/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 withIssues = j.results.filter(r => !r.ok).length;
const requirements = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'requirement').length, 0);
const bestPractices = j.results.reduce((n, r) => n + r.issues.filter(i => i.severity === 'best-practice').length, 0);
const skipped = Number(j.skipped || 0);
const skippedReason = j.skippedReason || 'file(s).';
const skippedText = skipped ? ' Skipped ' + skipped + ' ' + skippedReason : '';
statusEl.textContent = 'Scanned ' + total + ' HTML file(s). ' + withIssues + ' file(s) need review.' + skippedText;
if (!total) { resultsEl.innerHTML = ''; return; }
resultsEl.innerHTML = '<table class="data-table"><thead><tr><th>File</th><th>Results</th></tr></thead><tbody></tbody></table>';
const tbody = resultsEl.querySelector('tbody');
for (const rr of j.results) {
const row = document.createElement('tr');
const file = document.createElement('td');
file.className = 'mono wrap';
file.textContent = rr.file;
const result = document.createElement('td');
result.className = 'wrap';
if (!rr.ok) {
const list = document.createElement('ul');
list.className = 'issues';
for (const i of rr.issues) {
const item = document.createElement('li');
item.className = 'issue';
const line = i.line ? ' line ' + i.line + ':' : '';
item.innerHTML = '<span class="badge ' + i.severity + '">' + escapeText(i.severity) + '</span><span class="issue-title">' + escapeText(line + ' ' + i.title) + '</span><div class="issue-detail">' + escapeText(i.detail) + '</div>';
list.appendChild(item);
}
result.appendChild(list);
} else {
result.innerHTML = '<span class="badge ok">OK</span>';
}
row.append(file, result);
tbody.appendChild(row);
}
});
</script>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(Page("/mraid", "MRAID Checker", body)))
}
func MraidPickFolder(w http.ResponseWriter, r *http.Request) {
p := PickFolder("Select folder to scan", "")
if p == "" {
writeJSON(w, map[string]any{"paths": []string{}})
return
}
files := []string{}
for _, f := range collectHTMLFiles(p) {
if isInSupportedMraidFolderPath(f) {
files = append(files, f)
}
}
writeJSON(w, map[string]any{"paths": files})
}
func MraidPickFiles(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})
}
func MraidScan(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": []mraidResult{}, "error": err.Error()})
return
}
var targets []string
var baseDir string
skipped := 0
skippedReason := ""
noTargetsError := "No HTML files were found."
if len(req.Files) > 0 {
var existing []string
for _, f := range req.Files {
if fileExists(f) {
existing = append(existing, f)
}
}
targets = existing
skipped = len(req.Files) - len(existing)
skippedReason = "missing file(s)."
noTargetsError = "No selected HTML files were found."
if len(targets) > 0 {
baseDir = commonBaseDir(targets)
}
} else if req.Folder != "" && fileExists(req.Folder) {
all := collectHTMLFiles(req.Folder)
for _, f := range all {
if isInSupportedMraidFolderPath(f) {
targets = append(targets, f)
}
}
skipped = len(all) - len(targets)
skippedReason = "HTML file(s) outside AppLovin/ironSource/Unity folders."
noTargetsError = "No AppLovin, ironSource, or Unity folder HTML files were found."
baseDir = req.Folder
} else {
writeJSON(w, map[string]any{"results": []mraidResult{}, "error": "Pick a folder or files first"})
return
}
if len(targets) == 0 {
writeJSON(w, map[string]any{"results": []mraidResult{}, "skipped": skipped, "skippedReason": skippedReason, "error": noTargetsError})
return
}
results := []mraidResult{}
for _, file := range targets {
data, err := os.ReadFile(file)
if err != nil {
continue
}
issues := checkMraid(string(data))
display := file
if baseDir != "" {
if rel, err := filepath.Rel(baseDir, file); err == nil && rel != "" {
display = rel
} else {
display = filepath.Base(file)
}
}
results = append(results, mraidResult{File: display, OK: len(issues) == 0, Issues: issues})
}
writeJSON(w, map[string]any{"results": results, "skipped": skipped, "skippedReason": skippedReason})
}
var (
mraidScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>(.*?)</script>`)
mraidCallRx = regexp.MustCompile(`(?i)\bmraid\s*\.\s*([a-zA-Z_$][\w$]*)\s*\(`)
mraidReferenceRx = regexp.MustCompile(`(?i)\bmraid\b`)
mraidWindowOpenRx = regexp.MustCompile(`(?i)\bwindow\s*\.\s*open\s*\(`)
mraidLocationNavRx = regexp.MustCompile(`(?i)\blocation\s*\.\s*(href|assign|replace)\b`)
mraidCloseUiRx = regexp.MustCompile(`(?i)\b(close|dismiss|skip)\b`)
mraidLoadingLeftRx = regexp.MustCompile(`(?i)mraid\s*\.\s*getState\s*\(\s*\)\s*={0,2}\s*['"]loading['"]`)
mraidLoadingRightRx = regexp.MustCompile(`(?i)['"]loading['"]\s*={0,2}\s*mraid\s*\.\s*getState\s*\(\s*\)`)
)
const mraidReference = "MRAID 3.0 specification and MRAID 3.0 Best Practices Guide in mraidDocuments/"
func isInSupportedMraidFolderPath(filePath string) bool {
supported := map[string]bool{
"al": true,
"applovin": true,
"app-lovin": true,
"is": true,
"ironsource": true,
"iron-source": true,
"iron_source": true,
"un": true,
"unity": true,
"unityads": true,
"unity-ads": true,
"unity_ads": true,
}
parts := strings.FieldsFunc(strings.ToLower(filepath.Dir(filePath)), func(r rune) bool {
return r == '/' || r == '\\'
})
for _, part := range parts {
if supported[part] {
return true
}
}
return false
}
func checkMraid(htmlSrc string) []mraidIssue {
ctx := mraidContext{
HTML: htmlSrc,
ScriptText: extractMraidScriptText(htmlSrc),
MraidCallLines: collectMraidCallLines(htmlSrc),
}
var out []mraidIssue
for _, rule := range mraidRules {
if found := rule.Test(ctx, rule); found != nil {
out = append(out, *found)
}
}
if out == nil {
out = []mraidIssue{}
}
return out
}
func extractMraidScriptText(htmlSrc string) string {
var parts []string
for _, m := range mraidScriptRx.FindAllStringSubmatch(htmlSrc, -1) {
parts = append(parts, m[1])
}
return strings.Join(parts, "\n")
}
func collectMraidCallLines(htmlSrc string) map[string]int {
out := map[string]int{}
for idx, line := range strings.Split(htmlSrc, "\n") {
for _, m := range mraidCallRx.FindAllStringSubmatch(line, -1) {
if _, ok := out[m[1]]; !ok {
out[m[1]] = idx + 1
}
}
}
return out
}
func mraidHasReference(ctx mraidContext) bool { return mraidReferenceRx.MatchString(ctx.HTML) }
func mraidHasCall(ctx mraidContext, method string) bool {
_, ok := ctx.MraidCallLines[method]
return ok
}
func mraidLine(ctx mraidContext, method string) int { return ctx.MraidCallLines[method] }
func mraidHasEventListener(ctx mraidContext, eventName string) bool {
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*addEventListener\s*\(\s*['"]` + regexp.QuoteMeta(eventName) + `['"]`)
return rx.MatchString(ctx.ScriptText)
}
func mraidHasSupportCheck(ctx mraidContext, feature string) bool {
rx := regexp.MustCompile(`(?i)mraid\s*\.\s*supports\s*\(\s*['"]` + regexp.QuoteMeta(feature) + `['"]`)
return rx.MatchString(ctx.ScriptText)
}
func mraidHasReadyGate(ctx mraidContext) bool {
return mraidHasEventListener(ctx, "ready") || mraidLoadingLeftRx.MatchString(ctx.ScriptText) || mraidLoadingRightRx.MatchString(ctx.ScriptText)
}
func mraidIssueFrom(rule mraidRule, line int) *mraidIssue {
found := mraidIssue{ID: rule.ID, Severity: rule.Severity, Title: rule.Title, Detail: rule.Detail, Reference: rule.Reference}
if line > 0 {
found.Line = line
}
return &found
}
var mraidRules = []mraidRule{
{ID: "mraid-reference", Severity: "requirement", Title: "MRAID API reference not found", Detail: "Playable HTML should use the injected mraid object when it depends on an MRAID container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasReference(ctx) {
return nil
}
return mraidIssueFrom(rule, 0)
}},
{ID: "ready-gate", Severity: "requirement", Title: "MRAID ready state is not guarded", Detail: "Gate ad startup behind mraid.ready, or call startup immediately only when mraid.getState() is not loading.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if !mraidHasReference(ctx) || mraidHasReadyGate(ctx) {
return nil
}
return mraidIssueFrom(rule, 0)
}},
{ID: "error-listener", Severity: "best-practice", Title: "No MRAID error listener found", Detail: "Listen for mraid error events so unsupported or failed MRAID calls can be logged or handled gracefully.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if !mraidHasReference(ctx) || mraidHasEventListener(ctx, "error") {
return nil
}
return mraidIssueFrom(rule, 0)
}},
{ID: "viewability", Severity: "best-practice", Title: "Viewability handling not found", Detail: "Use mraid.isViewable() and/or viewableChange before starting animation, audio, video, or timers.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if !mraidHasReference(ctx) || mraidHasCall(ctx, "isViewable") || mraidHasEventListener(ctx, "viewableChange") || mraidHasEventListener(ctx, "exposureChange") {
return nil
}
return mraidIssueFrom(rule, 0)
}},
{ID: "open-api", Severity: "requirement", Title: "Browser navigation used instead of mraid.open", Detail: "Use mraid.open(url) for click-through navigation inside an MRAID ad container.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if (mraidWindowOpenRx.MatchString(ctx.ScriptText) || mraidLocationNavRx.MatchString(ctx.ScriptText)) && !mraidHasCall(ctx, "open") {
return mraidIssueFrom(rule, 0)
}
return nil
}},
{ID: "close-api", Severity: "best-practice", Title: "No mraid.close call found", Detail: "If the creative renders its own close control, wire that control to mraid.close().", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidCloseUiRx.MatchString(ctx.HTML) && !mraidHasCall(ctx, "close") {
return mraidIssueFrom(rule, 0)
}
return nil
}},
{ID: "expand-support", Severity: "requirement", Title: "mraid.expand used without supports check", Detail: "Check mraid.supports(\"expand\") before using expand behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasCall(ctx, "expand") && !mraidHasSupportCheck(ctx, "expand") {
return mraidIssueFrom(rule, mraidLine(ctx, "expand"))
}
return nil
}},
{ID: "resize-support", Severity: "requirement", Title: "mraid.resize used without supports check", Detail: "Check mraid.supports(\"resize\") before using resize behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasCall(ctx, "resize") && !mraidHasSupportCheck(ctx, "resize") {
return mraidIssueFrom(rule, mraidLine(ctx, "resize"))
}
return nil
}},
{ID: "orientation-support", Severity: "best-practice", Title: "Orientation properties set without supports check", Detail: "Check mraid.supports(\"orientation\") before requiring orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
if mraidHasCall(ctx, "setOrientationProperties") && !mraidHasSupportCheck(ctx, "orientation") {
return mraidIssueFrom(rule, mraidLine(ctx, "setOrientationProperties"))
}
return nil
}},
{ID: "size-change", Severity: "best-practice", Title: "No sizeChange listener found", Detail: "Listen for sizeChange when layout depends on container size, resize, expand, or orientation behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
depends := mraidHasCall(ctx, "getMaxSize") || mraidHasCall(ctx, "getScreenSize") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "setOrientationProperties")
if depends && !mraidHasEventListener(ctx, "sizeChange") {
return mraidIssueFrom(rule, 0)
}
return nil
}},
{ID: "state-change", Severity: "best-practice", Title: "No stateChange listener found", Detail: "Listen for stateChange when using expand, resize, close, or other state-changing container behavior.", Reference: mraidReference, Test: func(ctx mraidContext, rule mraidRule) *mraidIssue {
changes := mraidHasCall(ctx, "expand") || mraidHasCall(ctx, "resize") || mraidHasCall(ctx, "close")
if changes && !mraidHasEventListener(ctx, "stateChange") {
return mraidIssueFrom(rule, 0)
}
return nil
}},
}