632 lines
21 KiB
Go
632 lines
21 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math/rand"
|
||
"mime/multipart"
|
||
"net/http"
|
||
urlpkg "net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var applovinUserAgents = []string{
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||
}
|
||
|
||
func pickApplovinUserAgent(randomize bool) string {
|
||
if !randomize {
|
||
return applovinUserAgents[0]
|
||
}
|
||
return applovinUserAgents[rand.Intn(len(applovinUserAgents))]
|
||
}
|
||
|
||
func ApplovinPage(w http.ResponseWriter, r *http.Request) {
|
||
body := `
|
||
<header class="tool-header">
|
||
<h2 class="tool-title">AppLovin Playable Preview</h2>
|
||
<p class="tool-description">Upload HTML files to p.applov.in and get QR codes to scan with the AppLovin Playable Preview app.</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="pick" 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="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||
<div id="fileList" class="selected-files"></div>
|
||
</div>
|
||
<div class="action-row">
|
||
<button id="upload">Upload</button>
|
||
<button id="saveAll" class="secondary" disabled>Save All</button>
|
||
<button id="regenAll" class="secondary" disabled>Regenerate</button>
|
||
</div>
|
||
<div id="status" class="status-panel"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="results" class="results-panel"></div>
|
||
|
||
<style>
|
||
.qr-cell { text-align:center; }
|
||
.qr-cell img { width:104px; height:104px; max-width:100%; padding:6px; border-radius:4px; background:#fff; cursor:zoom-in; }
|
||
.result-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||
.qr-results:not(.has-errors) .error-col { display:none; }
|
||
.qr-modal { position:fixed; inset:0; display:none; align-items:center; justify-content:center; padding:24px; background:rgba(0,0,0,0.72); z-index:10; }
|
||
.qr-modal.open { display:flex; }
|
||
.qr-modal img { width:min(70vw,520px); height:min(70vw,520px); padding:18px; border-radius:4px; background:#fff; }
|
||
</style>
|
||
<div id="qrModal" class="qr-modal"><img alt="Expanded QR" /></div>
|
||
|
||
<script>
|
||
const PAGE_SIZE = 5;
|
||
let selectedPage = 0;
|
||
const pickBtn = document.getElementById('pick');
|
||
const pickFolderBtn = document.getElementById('pickFolder');
|
||
const clearBtn = document.getElementById('clear');
|
||
const uploadBtn = document.getElementById('upload');
|
||
const fileNameEl = document.getElementById('fileName');
|
||
const fileListEl = document.getElementById('fileList');
|
||
const statusEl = document.getElementById('status');
|
||
const resultsEl = document.getElementById('results');
|
||
const saveAllBtn = document.getElementById('saveAll');
|
||
const regenAllBtn = document.getElementById('regenAll');
|
||
const qrModal = document.getElementById('qrModal');
|
||
let selectedPaths = [];
|
||
let resultItems = [];
|
||
|
||
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 (!selectedPaths.length) {
|
||
fileNameEl.textContent = '(no files selected)';
|
||
fileListEl.innerHTML = '';
|
||
return;
|
||
}
|
||
fileNameEl.textContent = selectedPaths.length + ' file' + (selectedPaths.length === 1 ? '' : 's');
|
||
const maxPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||
selectedPage = Math.min(selectedPage, maxPage);
|
||
const start = selectedPage * PAGE_SIZE;
|
||
const visiblePaths = selectedPaths.slice(start, start + PAGE_SIZE);
|
||
fileListEl.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>' +
|
||
visiblePaths.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>';
|
||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||
btn.addEventListener('click', () => { selectedPaths.splice(Number(btn.dataset.index), 1); renderSelection(); });
|
||
});
|
||
if (selectedPaths.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>';
|
||
fileListEl.appendChild(pager);
|
||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||
}
|
||
}
|
||
|
||
saveAllBtn.addEventListener('click', async () => {
|
||
if (!resultItems.length) return;
|
||
saveAllBtn.disabled = true;
|
||
try {
|
||
const r = await fetch('/api/applovin/saveAllQrs', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ items: resultItems }),
|
||
});
|
||
const j = await r.json();
|
||
statusEl.innerHTML = j.ok
|
||
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
|
||
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
|
||
} finally {
|
||
saveAllBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
regenAllBtn.addEventListener('click', () => {
|
||
resultsEl.querySelectorAll('img[data-qr]').forEach(img => {
|
||
const base = img.getAttribute('data-qr');
|
||
img.src = base + '&_=' + Date.now();
|
||
});
|
||
});
|
||
setupDropZone('dropZone', statusEl, (j) => {
|
||
if (j.files && j.files.length) {
|
||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||
selectedPaths = selectedPaths.concat(added);
|
||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||
renderSelection();
|
||
}
|
||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||
});
|
||
|
||
clearBtn.addEventListener('click', () => {
|
||
selectedPaths = [];
|
||
selectedPage = 0;
|
||
renderSelection();
|
||
});
|
||
|
||
pickFolderBtn.addEventListener('click', async () => {
|
||
const r = await fetch('/api/applovin/pickFolder', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.files && j.files.length) {
|
||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||
selectedPaths = selectedPaths.concat(added);
|
||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||
renderSelection();
|
||
}
|
||
});
|
||
pickBtn.addEventListener('click', async () => {
|
||
const r = await fetch('/api/applovin/pick', { method: 'POST' });
|
||
const j = await r.json();
|
||
if (j.files && j.files.length) {
|
||
const added = j.files.map(f => f.path).filter(p => !selectedPaths.includes(p));
|
||
selectedPaths = selectedPaths.concat(added);
|
||
selectedPage = Math.max(0, Math.ceil(selectedPaths.length / PAGE_SIZE) - 1);
|
||
renderSelection();
|
||
}
|
||
});
|
||
|
||
uploadBtn.addEventListener('click', async () => {
|
||
statusEl.textContent = '';
|
||
statusEl.classList.remove('is-busy');
|
||
resultsEl.innerHTML = '';
|
||
if (!selectedPaths.length) {
|
||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||
return;
|
||
}
|
||
uploadBtn.disabled = true;
|
||
statusEl.textContent = 'Uploading...';
|
||
statusEl.classList.add('is-busy');
|
||
resultItems = [];
|
||
saveAllBtn.disabled = true;
|
||
regenAllBtn.disabled = true;
|
||
try {
|
||
const res = await fetch('/api/applovin/upload', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ paths: selectedPaths }),
|
||
});
|
||
if (!res.ok || !res.body) {
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="err">Upload failed.</span>';
|
||
uploadBtn.disabled = false;
|
||
return;
|
||
}
|
||
const reader = res.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '';
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
let nl;
|
||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
||
if (!line.trim()) continue;
|
||
handle(JSON.parse(line));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||
} finally {
|
||
uploadBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
function escapeHtml(s){
|
||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
function ensureResultsTable() {
|
||
let tbody = resultsEl.querySelector('tbody');
|
||
if (tbody) return tbody;
|
||
resultsEl.innerHTML = '<table class="data-table qr-results"><thead><tr><th>File</th><th style="width:140px;">QR</th><th style="width:170px;">Actions</th><th class="error-col">Error</th></tr></thead><tbody></tbody></table>';
|
||
return resultsEl.querySelector('tbody');
|
||
}
|
||
qrModal.addEventListener('click', () => qrModal.classList.remove('open'));
|
||
|
||
function handle(m) {
|
||
if (m.type === 'status') {
|
||
statusEl.textContent = m.message;
|
||
statusEl.classList.add('is-busy');
|
||
} else if (m.type === 'fileError') {
|
||
const tbody = ensureResultsTable();
|
||
resultsEl.querySelector('table').classList.add('has-errors');
|
||
const row = document.createElement('tr');
|
||
row.innerHTML = '<td class="mono wrap">' + escapeHtml(m.name) + '</td><td class="muted">-</td><td class="muted">-</td><td class="err wrap error-col">' + escapeHtml(m.message) + '</td>';
|
||
tbody.appendChild(row);
|
||
} else if (m.type === 'fileResult') {
|
||
const tbody = ensureResultsTable();
|
||
const row = document.createElement('tr');
|
||
const file = document.createElement('td');
|
||
file.className = 'mono wrap';
|
||
file.textContent = m.name;
|
||
const qr = document.createElement('td');
|
||
qr.className = 'qr-cell';
|
||
qr.innerHTML = '<img src="' + m.qrImg + '" data-qr="' + m.qrImg + '" alt="QR" />';
|
||
qr.querySelector('img').addEventListener('click', (event) => {
|
||
qrModal.querySelector('img').src = event.currentTarget.src;
|
||
qrModal.classList.add('open');
|
||
});
|
||
const actionCell = document.createElement('td');
|
||
const error = document.createElement('td');
|
||
error.className = 'error-col';
|
||
error.innerHTML = '<span class="muted">-</span>';
|
||
const actions = document.createElement('div');
|
||
actions.className = 'result-actions';
|
||
const openBtn = document.createElement('button');
|
||
openBtn.textContent = 'Open';
|
||
openBtn.className = 'secondary';
|
||
openBtn.onclick = () => fetch('/api/open', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:m.previewUrl})});
|
||
const saveQr = document.createElement('button');
|
||
saveQr.textContent = 'Save QR';
|
||
saveQr.className = 'secondary';
|
||
saveQr.onclick = async () => {
|
||
saveQr.disabled = true;
|
||
try {
|
||
const r = await fetch('/api/applovin/saveQr', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ name: m.name, url: m.qrImg }),
|
||
});
|
||
const j = await r.json();
|
||
statusEl.innerHTML = j.ok
|
||
? '<span class="ok">' + escapeHtml(j.message || 'Saved.') + '</span>'
|
||
: '<span class="err">' + escapeHtml(j.message || 'Save failed.') + '</span>';
|
||
} finally {
|
||
saveQr.disabled = false;
|
||
}
|
||
};
|
||
actions.appendChild(openBtn); actions.appendChild(saveQr);
|
||
actionCell.appendChild(actions);
|
||
row.append(file, qr, actionCell, error);
|
||
tbody.appendChild(row);
|
||
resultItems.push({ name: m.name, url: m.qrImg });
|
||
saveAllBtn.disabled = resultItems.length === 0;
|
||
regenAllBtn.disabled = resultItems.length === 0;
|
||
} else if (m.type === 'done') {
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="ok">Done.</span>';
|
||
}
|
||
}
|
||
</script>
|
||
`
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_, _ = w.Write([]byte(Page("/applovin", "AppLovin Playable Preview", body)))
|
||
}
|
||
|
||
func ApplovinPick(w http.ResponseWriter, r *http.Request) {
|
||
cfg := LoadConfig()
|
||
picked := PickFiles(
|
||
"Select HTML files",
|
||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||
true, cfg.LastPickDir,
|
||
)
|
||
if len(picked) > 0 {
|
||
cfg.LastPickDir = filepath.Dir(picked[0])
|
||
_ = SaveConfig(cfg)
|
||
}
|
||
files := make([]map[string]string, 0, len(picked))
|
||
for _, p := range picked {
|
||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||
}
|
||
writeJSON(w, map[string]any{"files": files})
|
||
}
|
||
|
||
func ApplovinPickFolder(w http.ResponseWriter, r *http.Request) {
|
||
cfg := LoadConfig()
|
||
dir := PickFolder("Select folder", cfg.LastPickDir)
|
||
if dir == "" {
|
||
writeJSON(w, map[string]any{"files": []any{}})
|
||
return
|
||
}
|
||
cfg.LastPickDir = dir
|
||
_ = SaveConfig(cfg)
|
||
paths := collectHTMLFiles(dir)
|
||
files := make([]map[string]string, 0, len(paths))
|
||
for _, p := range paths {
|
||
files = append(files, map[string]string{"path": p, "name": filepath.Base(p)})
|
||
}
|
||
writeJSON(w, map[string]any{"files": files})
|
||
}
|
||
|
||
func ApplovinUpload(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Paths []string `json:"paths"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
cfg := LoadConfig().Applovin
|
||
cookie := cfg.Cookie
|
||
delayMs := cfg.DelayMs
|
||
if delayMs < 0 {
|
||
delayMs = 0
|
||
}
|
||
randomizeUA := true
|
||
if cfg.RandomizeUserAgent != nil {
|
||
randomizeUA = *cfg.RandomizeUserAgent
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||
flusher, _ := w.(http.Flusher)
|
||
|
||
send := func(obj any) {
|
||
data, _ := json.Marshal(obj)
|
||
_, _ = w.Write(append(data, '\n'))
|
||
if flusher != nil {
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
|
||
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
||
|
||
buildForm := func(filePath string) (*bytes.Buffer, string, error) {
|
||
data, err := os.ReadFile(filePath)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
fw, err := createFormFileWithType(mw, "file", filepath.Base(filePath), "text/html")
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if _, err := fw.Write(data); err != nil {
|
||
return nil, "", err
|
||
}
|
||
if err := mw.Close(); err != nil {
|
||
return nil, "", err
|
||
}
|
||
return &buf, mw.FormDataContentType(), nil
|
||
}
|
||
|
||
doPost := func(url string, body *bytes.Buffer, contentType string) (int, string, error) {
|
||
req, err := http.NewRequest("POST", url, body)
|
||
if err != nil {
|
||
return 0, "", err
|
||
}
|
||
req.Header.Set("Content-Type", contentType)
|
||
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
||
req.Header.Set("Origin", "https://p.applov.in")
|
||
req.Header.Set("Referer", "https://p.applov.in/playablePreview?create=1&qr=1")
|
||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||
req.Header.Set("User-Agent", pickApplovinUserAgent(randomizeUA))
|
||
if cookie != "" {
|
||
req.Header.Set("Cookie", "__Host-APPLOVINID="+cookie)
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return 0, "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
out, _ := io.ReadAll(resp.Body)
|
||
return resp.StatusCode, string(out), nil
|
||
}
|
||
|
||
for i, filePath := range req.Paths {
|
||
fileName := filepath.Base(filePath)
|
||
idx := i + 1
|
||
fileErr := func(msg string) {
|
||
send(map[string]any{"type": "fileError", "name": fileName, "message": msg})
|
||
}
|
||
|
||
if filePath == "" || !fileExists(filePath) {
|
||
fileErr("File missing")
|
||
continue
|
||
}
|
||
if !htmlRx.MatchString(filePath) {
|
||
fileErr("Must be .html")
|
||
continue
|
||
}
|
||
|
||
if i > 0 && delayMs > 0 {
|
||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Waiting %d/%d %s", idx, len(req.Paths), fileName)})
|
||
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
||
}
|
||
|
||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Validating %d/%d %s", idx, len(req.Paths), fileName)})
|
||
|
||
body, ct, err := buildForm(filePath)
|
||
if err != nil {
|
||
fileErr("Read failed: " + err.Error())
|
||
continue
|
||
}
|
||
status, respText, err := doPost("https://p.applov.in/validateHTMLContent", body, ct)
|
||
if err != nil {
|
||
fileErr("Network (validate): " + err.Error())
|
||
continue
|
||
}
|
||
if status < 200 || status >= 300 {
|
||
fileErr(fmt.Sprintf("Validate HTTP %d: %s", status, truncate(respText, 300)))
|
||
continue
|
||
}
|
||
var vJSON map[string]any
|
||
_ = json.Unmarshal([]byte(respText), &vJSON)
|
||
if code, ok := vJSON["code"]; ok {
|
||
if codeNum, isNum := code.(float64); isNum && codeNum != 200 {
|
||
fileErr("Validation failed: " + truncate(respText, 300))
|
||
continue
|
||
}
|
||
}
|
||
|
||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d/%d %s", idx, len(req.Paths), fileName)})
|
||
|
||
body, ct, err = buildForm(filePath)
|
||
if err != nil {
|
||
fileErr("Read failed: " + err.Error())
|
||
continue
|
||
}
|
||
status, respText, err = doPost("https://p.applov.in/getCachedAdURL", body, ct)
|
||
if err != nil {
|
||
fileErr("Network (cache): " + err.Error())
|
||
continue
|
||
}
|
||
if status < 200 || status >= 300 {
|
||
fileErr(fmt.Sprintf("Cache HTTP %d: %s", status, truncate(respText, 300)))
|
||
continue
|
||
}
|
||
var cJSON map[string]any
|
||
if err := json.Unmarshal([]byte(respText), &cJSON); err != nil {
|
||
fileErr("Non-JSON: " + truncate(respText, 300))
|
||
continue
|
||
}
|
||
hash, _ := cJSON["results"].(string)
|
||
if hash == "" {
|
||
fileErr("No hash in response: " + truncate(respText, 300))
|
||
continue
|
||
}
|
||
|
||
send(map[string]any{
|
||
"type": "fileResult",
|
||
"name": fileName,
|
||
"hash": hash,
|
||
"previewUrl": "https://p.applov.in/getPreviewHTML?preview=" + hash,
|
||
"pageUrl": "https://p.applov.in/playablePreview?preview=" + hash + "&qr=1",
|
||
"qrImg": "https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=" + urlpkg.QueryEscape(hash),
|
||
})
|
||
}
|
||
send(map[string]any{"type": "done"})
|
||
}
|
||
|
||
func qrBaseName(name string) string {
|
||
base := name
|
||
if base == "" {
|
||
base = "qr"
|
||
}
|
||
base = regexp.MustCompile(`(?i)\.html?$`).ReplaceAllString(base, "")
|
||
base = regexp.MustCompile(`[\\/:*?"<>|]`).ReplaceAllString(base, "_")
|
||
if base == "" {
|
||
base = "qr"
|
||
}
|
||
return base
|
||
}
|
||
|
||
func downloadQR(url, destPath string) error {
|
||
resp, err := http.Get(url)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(destPath, data, 0644)
|
||
}
|
||
|
||
func ApplovinSaveQR(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
|
||
return
|
||
}
|
||
if req.URL == "" {
|
||
writeJSON(w, map[string]any{"ok": false, "message": "Missing URL"})
|
||
return
|
||
}
|
||
base := qrBaseName(req.Name)
|
||
dest := PickSaveFile("Save QR", base+".png", []FileFilter{{Name: "PNG Image", Extensions: []string{"png"}}}, "")
|
||
if dest == "" {
|
||
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
|
||
return
|
||
}
|
||
if err := downloadQR(req.URL, dest); err != nil {
|
||
writeJSON(w, map[string]any{"ok": false, "message": "Save failed: " + err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, map[string]any{"ok": true, "message": "Saved QR: " + dest})
|
||
}
|
||
|
||
func ApplovinSaveAllQRs(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Items []struct {
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
} `json:"items"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, map[string]any{"ok": false, "message": err.Error()})
|
||
return
|
||
}
|
||
if len(req.Items) == 0 {
|
||
writeJSON(w, map[string]any{"ok": false, "message": "No items"})
|
||
return
|
||
}
|
||
dir := PickFolder("Choose folder to save QRs", "")
|
||
if dir == "" {
|
||
writeJSON(w, map[string]any{"ok": false, "message": "Cancelled"})
|
||
return
|
||
}
|
||
saved := 0
|
||
errs := []string{}
|
||
for _, it := range req.Items {
|
||
base := qrBaseName(it.Name)
|
||
dest := filepath.Join(dir, base+".png")
|
||
n := 1
|
||
for fileExists(dest) {
|
||
dest = filepath.Join(dir, fmt.Sprintf("%s (%d).png", base, n))
|
||
n++
|
||
}
|
||
if err := downloadQR(it.URL, dest); err != nil {
|
||
errs = append(errs, it.Name+": "+err.Error())
|
||
continue
|
||
}
|
||
saved++
|
||
}
|
||
if len(errs) > 0 {
|
||
writeJSON(w, map[string]any{
|
||
"ok": false,
|
||
"message": fmt.Sprintf("Saved %d/%d. Errors: %s", saved, len(req.Items), strings.Join(errs, "; ")),
|
||
})
|
||
return
|
||
}
|
||
writeJSON(w, map[string]any{
|
||
"ok": true,
|
||
"message": fmt.Sprintf("Saved %d QR%s to %s", saved, plural(saved), dir),
|
||
})
|
||
}
|
||
|
||
func plural(n int) string {
|
||
if n == 1 {
|
||
return ""
|
||
}
|
||
return "s"
|
||
}
|