This commit is contained in:
2026-06-23 13:53:55 +08:00
parent c336f176e6
commit 331e49ea29
13 changed files with 557 additions and 28 deletions

131
PROJECT.md Normal file
View File

@@ -0,0 +1,131 @@
# HPL Toolbox — Project Summary for AI
## What this project is
HPL Toolbox is an internal tool for a playable ad team. It ships as **two separate deliverables** from the same repo:
1. **VS Code Extension** (`src/`) — a `.vsix` that adds tool panels as webview tabs inside VS Code.
2. **Standalone Windows App** (`standalone/`) — a Go + WebView2 desktop app that exposes the same tools through a local HTTP server rendered in a native window. It is a **real, complete, production app** — not a prototype or subset.
Both deliverables implement the same set of tools. New features are typically added to both.
---
## Directory structure
```
HPLToolbox-Extension/
├── src/
│ ├── extension.ts # Extension entry point, registers commands
│ ├── tools/ # One file per tool (VS Code webview panels)
│ │ ├── shared.ts # Shared utilities: singletonPanel, webview styles, clipboard/open handlers
│ │ ├── plecUpload.ts # PLEC Upload tool
│ │ ├── applovin.ts # AppLovin Playable Preview
│ │ ├── base64Scanner.ts # Base64 Scanner
│ │ ├── mraidChecker.ts # MRAID Checker
│ │ ├── mintegral.ts # Mintegral Checker
│ │ ├── sendToMobile.ts # Send To Mobile
│ │ ├── playworks.ts # Playworks Converter
│ │ ├── deviceSimulator.ts# Device Simulator
│ │ └── projectInit.ts # Initialize Project
│ └── tools.json # Tool launcher config (name, command, icon, description)
├── standalone/ # The Go standalone app — complete, production-grade
│ ├── main.go # Entry point: HTTP server + WebView2 window + route registration
│ ├── platform.go # AppConfig struct, defaultConfig(), LoadConfig/SaveConfig, OS helpers
│ ├── settings.go # /api/settings POST endpoint; settings modal HTML
│ ├── layout.go # Shared HTML layout, nav sidebar, CSS
│ ├── home.go # Home page / tool launcher
│ ├── plec.go # PLEC Upload: page HTML, pick endpoints, upload (legacy + WordPress)
│ ├── applovin.go # AppLovin upload
│ ├── base64.go # Base64 Scanner
│ ├── mraid.go # MRAID Checker
│ ├── mintegral.go # Mintegral Checker
│ ├── mobile.go # Send To Mobile
│ ├── playworks.go # Playworks Converter
│ ├── device_simulator.go # Device Simulator
│ ├── project_init.go # Initialize Project
│ ├── changelog.go # Changelog page
│ ├── json.go # newJSONStream helper (NDJSON streaming responses)
│ ├── update_checker.go # Auto-update check against Gitea release API
│ ├── single_instance.go # Single-instance lock via TCP port file
│ └── assets/ # Embedded static assets (qrcode.min.js, mintegral preview util)
├── scripts/
│ ├── build.ps1 # Builds both .vsix and standalone exe into dist/
│ └── run-standalone.ps1 # Runs the standalone app in dev mode (go run .)
├── data/
│ └── manifest.json # Project Init manifest (team template files list)
├── aiAssets/ # Sample HTML files used for testing/reference
├── package.json # Extension manifest, VS Code settings contributions, npm scripts
├── tsconfig.json
└── README.md
```
---
## Tools
| Tool | Description |
|---|---|
| PLEC Upload | Upload HTML playable files to internal PLEC servers. Supports two servers: Default (167.99.227.249, legacy PHP) and Backup (20.255.60.183, WordPress). Backup server uses auto-login via credentials. |
| AppLovin Playable Preview | Upload HTML files to p.applov.in and generate QR preview links. |
| Base64 Scanner | Scan HTML files for external or non-base64 asset references. |
| Send To Mobile | Serve selected HTML files over LAN and display a QR code for mobile preview. |
| MRAID Checker | Check HTML files against MRAID requirements and best practices. |
| Mintegral Checker | Validate Mintegral playable ZIPs against PlayTurbo requirements with embedded runtime preview. |
| Playworks Converter | Convert Playworks HTML into per-network variants. |
| Device Simulator | Preview HTML playables inside accurate mobile device frames with notch/Dynamic Island overlays. |
| Initialize Project | Download team template files from a shared manifest (Google Drive or direct URL). |
---
## Architecture notes
### VS Code Extension
- Each tool is a singleton webview panel (`singletonPanel` in `shared.ts`).
- The panel HTML is returned as a template string from `getHtml()` inside each tool file.
- Communication between extension host and webview uses `panel.webview.postMessage` / `onDidReceiveMessage`.
- Settings are read from `vscode.workspace.getConfiguration('hplToolbox.*')` and contributed in `package.json`.
- Session-persistent UI state (e.g. last selected server) uses `context.globalState`.
### Standalone App
- Go HTTP server on a random localhost port, served into a WebView2 window.
- All pages return full HTML strings (no template files on disk).
- Long-running operations (uploads, scans) use `newJSONStream` to stream NDJSON progress events — the browser reads these with `ReadableStream` and updates the UI incrementally.
- Config is persisted to `%LOCALAPPDATA%\HPLToolbox\config.json` via `LoadConfig` / `SaveConfig`.
- `localStorage` is used for lightweight UI-only state (e.g. last selected server in PLEC Upload).
- Single-instance enforcement: on launch, tries to connect to a previous instance's port from a lock file and `POST /api/focus` to bring its window forward, then exits.
### PLEC Upload — dual server detail
- **Default (legacy)**: `POST http://167.99.227.249/src/upload_HTML_Experimental.php` with `fileUpload_N` / `iterationNameUpload_N` fields. Filenames are timestamped.
- **Backup (WordPress)**: `POST http://20.255.60.183/wp-admin/admin-ajax.php` with `action=plec_upload_files`, `nonce`, `rows` (JSON array), and `file_N` fields. Login flow: POST `/wp-login.php` → GET `/file-upload/` to extract nonce. Iteration name is used as the uploaded filename. Preview URL is constructed as `/preview?theme=calcite&n1=<original>&m1=<saved>`.
---
## Build
```powershell
npm run build # builds both .vsix and standalone .exe into dist/
npm run build -- -Extension # .vsix only
npm run build -- -Standalone # .exe only
npm run run-standalone # dev: go run . in standalone/
npm run compile # tsc only
npm run watch # tsc watch
```
Requires: Node.js, TypeScript (`npm install`), Go, optionally `vsce` and `rsrc`.
---
## Key config (VS Code settings)
All under `hplToolbox.*`:
| Key | Default | Purpose |
|---|---|---|
| `plec.uploadUrl` | `http://167.99.227.249/src/upload_HTML_Experimental.php` | Legacy server endpoint |
| `plec.originUrl` | `http://167.99.227.249` | Origin/Referer for legacy uploads |
| `plec.wpUsername` | `pm` | WordPress username for backup server |
| `plec.wpPassword` | _(set)_ | WordPress password for backup server |
| `applovin.cookie` | _(empty)_ | `__Host-APPLOVINID` cookie for p.applov.in |
| `deviceSimulator.devicesUrl` | Google Drive link | Remote devices.json URL |
| `deviceSimulator.languagesUrl` | Google Drive link | Remote languages.json URL |

View File

@@ -151,3 +151,16 @@ Changed
Fixed Fixed
- Fixed Device Simulator not appearing in the VS Code extension launcher due to a trailing comma in tools.json causing JSON.parse to fail and fall back to a hardcoded list that excluded it. - Fixed Device Simulator not appearing in the VS Code extension launcher due to a trailing comma in tools.json causing JSON.parse to fail and fall back to a hardcoded list that excluded it.
``` ```
**v1.0.1**
```
Added
- Added backup PLEC server (20.255.60.183) as a selectable upload target in PLEC Upload.
- Server dropdown in the PLEC Upload inputs panel — Default (167.99.227.249) or Backup (20.255.60.183).
- Backup server uses WordPress authentication (auto-login, nonce fetch) — no manual token management.
- Iteration name is used as the uploaded filename on the backup server.
- Selected server persists across sessions (VS Code globalState / localStorage in standalone).
Changed
- Build scripts moved to scripts/ and added as npm run build and npm run run-standalone.
```

Binary file not shown.

BIN
dist/hpl-toolbox-1.0.1.vsix vendored Normal file

Binary file not shown.

View File

@@ -2,7 +2,7 @@
"name": "hpl-toolbox", "name": "hpl-toolbox",
"displayName": "HPL Toolbox", "displayName": "HPL Toolbox",
"description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.", "description": "Bundled tools: PLEC Upload, AppLovin Playable Preview, Base64 Scanner, MRAID Checker. Local HTML Host.",
"version": "1.0.0", "version": "1.0.1",
"publisher": "hesukastro", "publisher": "hesukastro",
"license": "UNLICENSED", "license": "UNLICENSED",
"repository": { "repository": {
@@ -103,6 +103,16 @@
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Skip the pre-check that verifies the filename does not already exist on the preview server." "description": "Skip the pre-check that verifies the filename does not already exist on the preview server."
},
"hplToolbox.plec.wpUsername": {
"type": "string",
"default": "pm",
"description": "WordPress username for the backup PLEC server (20.255.60.183)."
},
"hplToolbox.plec.wpPassword": {
"type": "string",
"default": "nRLz#LV@y#)q@CW@z8fGEPyI",
"description": "WordPress password for the backup PLEC server (20.255.60.183)."
} }
} }
}, },
@@ -167,7 +177,9 @@
"scripts": { "scripts": {
"compile": "tsc -p ./", "compile": "tsc -p ./",
"watch": "tsc -watch -p ./", "watch": "tsc -watch -p ./",
"vscode:prepublish": "npm run compile" "vscode:prepublish": "npm run compile",
"build": "pwsh -File scripts/build.ps1",
"run-standalone": "pwsh -File scripts/run-standalone.ps1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",

View File

@@ -15,7 +15,7 @@ param(
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $repoRoot Set-Location $repoRoot
# If neither was specified, build both. # If neither was specified, build both.

View File

@@ -1,6 +1,6 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path $root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$standalone = Join-Path $root 'standalone' $standalone = Join-Path $root 'standalone'
Push-Location $standalone Push-Location $standalone

View File

@@ -6,15 +6,25 @@ import { getToolWebviewStyles, handleClipboardAndOpen, singletonPanel } from './
const store: { panel: vscode.WebviewPanel | null } = { panel: null }; const store: { panel: vscode.WebviewPanel | null } = { panel: null };
export function openPlecUpload(_context: vscode.ExtensionContext) { export function openPlecUpload(context: vscode.ExtensionContext) {
const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload'); const { panel, isNew } = singletonPanel(store, 'hplToolbox.plecUpload', 'PLEC Upload');
if (!isNew) return; if (!isNew) return;
panel.webview.html = getHtml(); panel.webview.html = getHtml();
// Restore last selected server
const lastServer = context.globalState.get<string>('plec.lastServer', 'legacy');
panel.webview.postMessage({ type: 'restoreServer', value: lastServer });
panel.webview.onDidReceiveMessage(async (msg) => { panel.webview.onDidReceiveMessage(async (msg) => {
try { try {
if (handleClipboardAndOpen(msg)) return; if (handleClipboardAndOpen(msg)) return;
if (msg.type === 'saveServer') {
context.globalState.update('plec.lastServer', msg.value);
return;
}
if (msg.type === 'pickFolder') { if (msg.type === 'pickFolder') {
const picked = await vscode.window.showOpenDialog({ const picked = await vscode.window.showOpenDialog({
canSelectFolders: true, canSelectFolders: true,
@@ -51,7 +61,7 @@ export function openPlecUpload(_context: vscode.ExtensionContext) {
} }
if (msg.type === 'upload') { if (msg.type === 'upload') {
await handleUpload(panel, msg.rows); await handleUpload(panel, msg.rows, msg.server);
return; return;
} }
} catch (err: any) { } catch (err: any) {
@@ -66,11 +76,7 @@ interface UploadRow {
iteration: string; iteration: string;
} }
async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) { async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[], server: string) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const uploadUrl = cfg.get<string>('uploadUrl')!;
const originUrl = cfg.get<string>('originUrl')!;
const valid: UploadRow[] = []; const valid: UploadRow[] = [];
for (const r of rows) { for (const r of rows) {
if (!r.path || !fs.existsSync(r.path)) { if (!r.path || !fs.existsSync(r.path)) {
@@ -92,11 +98,21 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
return; return;
} }
if (server === 'wordpress') {
await handleUploadWordPress(panel, valid);
} else {
await handleUploadLegacy(panel, valid);
}
}
async function handleUploadLegacy(panel: vscode.WebviewPanel, valid: UploadRow[]) {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const uploadUrl = cfg.get<string>('uploadUrl')!;
const originUrl = cfg.get<string>('originUrl')!;
const stamp = timestampSuffix(); const stamp = timestampSuffix();
const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp)); const stampedNames = valid.map((r) => appendTimestamp(path.basename(r.path), stamp));
panel.webview.postMessage({ type: 'status', message: 'Uploading...' });
const form = new FormData(); const form = new FormData();
for (let i = 0; i < valid.length; i++) { for (let i = 0; i < valid.length; i++) {
const r = valid[i]; const r = valid[i];
@@ -129,20 +145,14 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
const text = await res.text(); const text = await res.text();
if (!res.ok) { if (!res.ok) {
panel.webview.postMessage({ panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
type: 'error',
message: `HTTP ${res.status}\n${text.slice(0, 800)}`,
});
return; return;
} }
let json: any; let json: any;
try { json = JSON.parse(text); } try { json = JSON.parse(text); }
catch { catch {
panel.webview.postMessage({ panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
type: 'error',
message: `Server returned non-JSON:\n${text.slice(0, 800)}`,
});
return; return;
} }
@@ -150,6 +160,154 @@ async function handleUpload(panel: vscode.WebviewPanel, rows: UploadRow[]) {
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text }); panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
} }
async function wpAuthenticate(panel: vscode.WebviewPanel): Promise<{ cookie: string; nonce: string }> {
const cfg = vscode.workspace.getConfiguration('hplToolbox.plec');
const username = cfg.get<string>('wpUsername') || '';
const password = cfg.get<string>('wpPassword') || '';
const baseUrl = 'http://20.255.60.183';
if (!username || !password) {
throw new Error('WordPress credentials not set. Add hplToolbox.plec.wpUsername and wpPassword in VS Code settings.');
}
panel.webview.postMessage({ type: 'status', message: 'Authenticating with backup server...' });
const loginBody = new URLSearchParams({
log: username,
pwd: password,
'wp-submit': 'Log In',
redirect_to: '/wp-admin/',
testcookie: '1',
});
const loginRes = await fetch(`${baseUrl}/wp-login.php`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': 'wordpress_test_cookie=WP Cookie check',
'Origin': baseUrl,
'Referer': `${baseUrl}/wp-login.php`,
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
},
body: loginBody.toString(),
redirect: 'manual',
});
const location = loginRes.headers.get('location') || '';
// Extract Set-Cookie headers — try getSetCookie() first (Node 18+), fall back to entries()
let rawCookies: string[] = [];
if (typeof (loginRes.headers as any).getSetCookie === 'function') {
rawCookies = (loginRes.headers as any).getSetCookie();
} else {
for (const [k, v] of (loginRes.headers as any).entries()) {
if (k.toLowerCase() === 'set-cookie') rawCookies.push(v);
}
}
const cookie = rawCookies.map((c: string) => c.split(';')[0]).join('; ');
// WordPress sends a 302 to /wp-admin/ on success, or 200 (login page with error) on failure
const loginOk = (loginRes.status === 301 || loginRes.status === 302) && !location.includes('wp-login');
if (!loginOk) {
const loginHtml = await loginRes.text();
const errorMatch = loginHtml.match(/<div id="login_error">([\s\S]*?)<\/div>/);
const wpError = errorMatch
? errorMatch[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
: null;
const bodyPreview = loginHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
throw new Error(
`WordPress login failed (HTTP ${loginRes.status}).\n` +
(wpError ? `Server: "${wpError}"\n` : '') +
`Body: ${bodyPreview}`
);
}
if (!cookie) {
throw new Error(`Login succeeded (redirected to ${location}) but no session cookies were returned. Check server config.`);
}
panel.webview.postMessage({ type: 'status', message: 'Fetching upload token...' });
const pageRes = await fetch(`${baseUrl}/file-upload/`, {
headers: { 'Cookie': cookie, 'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)' },
});
const pageText = await pageRes.text();
const match = pageText.match(/"nonce"\s*:\s*"([a-f0-9]+)"/);
if (!match) {
throw new Error('Could not extract nonce from backup server page. Are you sure the account has access?');
}
return { cookie, nonce: match[1] };
}
async function handleUploadWordPress(panel: vscode.WebviewPanel, valid: UploadRow[]) {
const { cookie, nonce } = await wpAuthenticate(panel);
const baseUrl = 'http://20.255.60.183';
const form = new FormData();
form.append('action', 'plec_upload_files');
form.append('nonce', nonce);
const rowsMeta: { field: string; iterationName: string }[] = [];
for (let i = 0; i < valid.length; i++) {
const r = valid[i];
const idx = i + 1;
const fieldName = `file_${idx}`;
const sanitized = r.iteration.replace(/\s+/g, '');
panel.webview.postMessage({ type: 'status', message: `Preparing ${idx}/${valid.length} ${sanitized}.html` });
const buf = fs.readFileSync(r.path);
const file = new File([buf], `${sanitized}.html`, { type: 'text/html' });
form.append(fieldName, file as any);
rowsMeta.push({ field: fieldName, iterationName: sanitized });
}
form.append('rows', JSON.stringify(rowsMeta));
panel.webview.postMessage({ type: 'status', message: `Uploading ${valid.length} file${valid.length === 1 ? '' : 's'}...` });
let res: Response;
try {
res = await fetch(`${baseUrl}/wp-admin/admin-ajax.php`, {
method: 'POST',
body: form,
headers: {
'Cookie': cookie,
'Origin': baseUrl,
'Referer': `${baseUrl}/file-upload/`,
'User-Agent': 'Mozilla/5.0 (HPL-Toolbox-VSCode)',
'X-Requested-With': 'XMLHttpRequest',
'Accept': '*/*',
},
});
} catch (err: any) {
panel.webview.postMessage({ type: 'error', message: 'Network error: ' + (err?.message || err) });
return;
}
const text = await res.text();
if (!res.ok) {
panel.webview.postMessage({ type: 'error', message: `HTTP ${res.status}\n${text.slice(0, 800)}` });
return;
}
let json: any;
try { json = JSON.parse(text); }
catch {
panel.webview.postMessage({ type: 'error', message: `Server returned non-JSON:\n${text.slice(0, 800)}` });
return;
}
const files: { original: string; saved: string }[] = json.data?.files || [];
let preview: string | null = json.preview || json.previewURL || null;
if (!preview && files.length > 0) {
const params = new URLSearchParams({ theme: 'calcite' });
files.forEach((f, i) => {
params.append(`n${i + 1}`, f.original.replace(/\.html?$/i, ''));
params.append(`m${i + 1}`, f.saved);
});
preview = `${baseUrl}/preview?${params.toString()}`;
}
panel.webview.postMessage({ type: 'result', preview, raw: json, rawText: text });
}
function timestampSuffix(d: Date = new Date()): string { function timestampSuffix(d: Date = new Date()): string {
const pad = (n: number, w = 2) => String(n).padStart(w, '0'); const pad = (n: number, w = 2) => String(n).padStart(w, '0');
return ( return (
@@ -225,6 +383,17 @@ ${getToolWebviewStyles()}
.result a { color: var(--vscode-textLink-foreground); } .result a { color: var(--vscode-textLink-foreground); }
.result-actions { margin-top: 8px; display: flex; gap: 8px; } .result-actions { margin-top: 8px; display: flex; gap: 8px; }
.pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; } .pager { margin-top: 8px; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.panel-header { display: flex; align-items: center; }
#serverSelect {
margin-left: auto;
background: var(--vscode-dropdown-background);
color: var(--vscode-dropdown-foreground);
border: 1px solid var(--vscode-dropdown-border);
padding: 3px 6px;
border-radius: 2px;
font-size: 12px;
cursor: pointer;
}
</style> </style>
</head> </head>
<body> <body>
@@ -237,6 +406,10 @@ ${getToolWebviewStyles()}
<section class="tool-panel input-panel"> <section class="tool-panel input-panel">
<div class="panel-header"> <div class="panel-header">
<h3 class="panel-title">Inputs</h3> <h3 class="panel-title">Inputs</h3>
<select id="serverSelect">
<option value="legacy">167.99.227.249 (Default)</option>
<option value="wordpress">20.255.60.183 (Backup)</option>
</select>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
@@ -377,6 +550,10 @@ ${getToolWebviewStyles()}
copyResultBtn.disabled = !previewUrl; copyResultBtn.disabled = !previewUrl;
} }
document.getElementById('serverSelect').addEventListener('change', (e) => {
vscode.postMessage({ type: 'saveServer', value: e.target.value });
});
document.getElementById('pickFolder').addEventListener('click', () => { document.getElementById('pickFolder').addEventListener('click', () => {
vscode.postMessage({ type: 'pickFolder' }); vscode.postMessage({ type: 'pickFolder' });
}); });
@@ -402,7 +579,7 @@ ${getToolWebviewStyles()}
} }
statusEl.textContent = 'Uploading...'; statusEl.textContent = 'Uploading...';
statusEl.classList.add('is-busy'); statusEl.classList.add('is-busy');
vscode.postMessage({ type: 'upload', rows }); vscode.postMessage({ type: 'upload', rows, server: document.getElementById('serverSelect').value });
}); });
openResultBtn.addEventListener('click', () => { openResultBtn.addEventListener('click', () => {
if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl }); if (previewUrl) vscode.postMessage({ type: 'open', text: previewUrl });
@@ -418,6 +595,10 @@ ${getToolWebviewStyles()}
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
const m = e.data; const m = e.data;
if (m.type === 'restoreServer') {
document.getElementById('serverSelect').value = m.value;
return;
}
if (m.type === 'filesSelected') { if (m.type === 'filesSelected') {
addFiles(m.files || []); addFiles(m.files || []);
} else if (m.type === 'status') { } else if (m.type === 'status') {

Binary file not shown.

View File

@@ -16,6 +16,8 @@ type PlecConfig struct {
OriginUrl string `json:"originUrl"` OriginUrl string `json:"originUrl"`
PreviewBase string `json:"previewBase"` PreviewBase string `json:"previewBase"`
SkipExistsCheck bool `json:"skipExistsCheck"` SkipExistsCheck bool `json:"skipExistsCheck"`
WpUsername string `json:"wpUsername"`
WpPassword string `json:"wpPassword"`
} }
type ApplovinConfig struct { type ApplovinConfig struct {
@@ -69,6 +71,8 @@ func defaultConfig() AppConfig {
OriginUrl: "http://167.99.227.249", OriginUrl: "http://167.99.227.249",
PreviewBase: "https://playable.applovindemo.com/phaser/", PreviewBase: "https://playable.applovindemo.com/phaser/",
SkipExistsCheck: false, SkipExistsCheck: false,
WpUsername: "pm",
WpPassword: "nRLz#LV@y#)q@CW@z8fGEPyI",
}, },
Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000}, Applovin: ApplovinConfig{Cookie: "", DelayMs: 5000},
SendToMobile: SendToMobileConfig{Port: 0}, SendToMobile: SendToMobileConfig{Port: 0},

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/cookiejar"
urlpkg "net/url" urlpkg "net/url"
"os" "os"
"path/filepath" "path/filepath"
@@ -23,7 +24,13 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
</header> </header>
<section class="tool-panel"> <section class="tool-panel">
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div> <div class="panel-header">
<h3 class="panel-title">Inputs</h3>
<select id="serverSelect">
<option value="legacy">167.99.227.249 (Default)</option>
<option value="wordpress">20.255.60.183 (Backup)</option>
</select>
</div>
<div class="panel-body"> <div class="panel-body">
<div class="control-group"> <div class="control-group">
<div id="dropZone" class="drop-zone"> <div id="dropZone" class="drop-zone">
@@ -54,10 +61,15 @@ func PlecPage(w http.ResponseWriter, r *http.Request) {
<style> <style>
.row-error { color:#f48771; font-size:12px; margin-top:4px; } .row-error { color:#f48771; font-size:12px; margin-top:4px; }
#rows input[type=text] { width: 100%; } #rows input[type=text] { width: 100%; }
.panel-header { display: flex; align-items: center; }
#serverSelect { margin-left:auto; padding:3px 6px; font-size:12px; cursor:pointer; }
</style> </style>
<script> <script>
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
const serverSelect = document.getElementById('serverSelect');
serverSelect.value = localStorage.getItem('plec.lastServer') || 'legacy';
serverSelect.addEventListener('change', () => localStorage.setItem('plec.lastServer', serverSelect.value));
let nextId = 1; let nextId = 1;
let rows = []; let rows = [];
let page = 0; let page = 0;
@@ -206,7 +218,7 @@ document.getElementById('upload').addEventListener('click', async () => {
const res = await fetch('/api/plec/upload', { const res = await fetch('/api/plec/upload', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows }), body: JSON.stringify({ rows, server: serverSelect.value }),
}); });
await readJSONStream(res, (msg) => { await readJSONStream(res, (msg) => {
if (msg.type === 'status') { if (msg.type === 'status') {
@@ -300,6 +312,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
send := newJSONStream(w) send := newJSONStream(w)
var req struct { var req struct {
Rows []plecRow `json:"rows"` Rows []plecRow `json:"rows"`
Server string `json:"server"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
send(map[string]any{"type": "error", "message": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
@@ -335,6 +348,14 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Server == "wordpress" {
plecUploadWordPress(send, cfg, valid)
} else {
plecUploadLegacy(send, cfg, valid)
}
}
func plecUploadLegacy(send func(any), cfg PlecConfig, valid []plecRow) {
stamp := time.Now().Format("20060102150405") stamp := time.Now().Format("20060102150405")
var buf bytes.Buffer var buf bytes.Buffer
@@ -357,9 +378,7 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
send(map[string]any{"type": "error", "message": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
} }
iter := urlpkg.QueryEscape(strings.ReplaceAll(row.Iteration, " ", "")) iter := encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
// Original uses encodeURI which keeps more chars than QueryEscape; emulate by un-escaping safe chars.
iter = encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil { if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
send(map[string]any{"type": "error", "message": err.Error()}) send(map[string]any{"type": "error", "message": err.Error()})
return return
@@ -408,6 +427,171 @@ func PlecUpload(w http.ResponseWriter, r *http.Request) {
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)}) send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
} }
func wpAuthenticate(send func(any), baseUrl, username, password string) (*http.Client, string, error) {
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
// Pre-seed the test cookie WordPress checks for
testURL, _ := urlpkg.Parse(baseUrl)
jar.SetCookies(testURL, []*http.Cookie{{Name: "wordpress_test_cookie", Value: "WP Cookie check"}})
send(map[string]any{"type": "status", "message": "Authenticating with backup server..."})
form := urlpkg.Values{
"log": {username},
"pwd": {password},
"wp-submit": {"Log In"},
"redirect_to": {"/wp-admin/"},
"testcookie": {"1"},
}
loginReq, _ := http.NewRequest("POST", baseUrl+"/wp-login.php", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginReq.Header.Set("Origin", baseUrl)
loginReq.Header.Set("Referer", baseUrl+"/wp-login.php")
loginReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
loginResp, err := client.Do(loginReq)
if err != nil {
return nil, "", fmt.Errorf("login request failed: %w", err)
}
io.ReadAll(loginResp.Body)
loginResp.Body.Close()
// After following redirects, check final URL — wp-admin means success
if strings.Contains(loginResp.Request.URL.String(), "wp-login") {
return nil, "", fmt.Errorf("WordPress login failed — check wpUsername / wpPassword in settings")
}
send(map[string]any{"type": "status", "message": "Fetching upload token..."})
pageReq, _ := http.NewRequest("GET", baseUrl+"/file-upload/", nil)
pageReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
pageResp, err := client.Do(pageReq)
if err != nil {
return nil, "", fmt.Errorf("nonce fetch failed: %w", err)
}
pageBody, _ := io.ReadAll(pageResp.Body)
pageResp.Body.Close()
nonceRx := regexp.MustCompile(`"nonce"\s*:\s*"([a-f0-9]+)"`)
m := nonceRx.FindSubmatch(pageBody)
if m == nil {
return nil, "", fmt.Errorf("could not extract nonce from backup server page")
}
return client, string(m[1]), nil
}
func plecUploadWordPress(send func(any), cfg PlecConfig, valid []plecRow) {
const baseUrl = "http://20.255.60.183"
if cfg.WpUsername == "" || cfg.WpPassword == "" {
send(map[string]any{"type": "error", "message": "WordPress credentials not set. Configure wpUsername / wpPassword in settings."})
return
}
client, nonce, err := wpAuthenticate(send, baseUrl, cfg.WpUsername, cfg.WpPassword)
if err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("action", "plec_upload_files")
_ = mw.WriteField("nonce", nonce)
type rowMeta struct {
Field string `json:"field"`
IterationName string `json:"iterationName"`
}
var rowsMeta []rowMeta
for i, row := range valid {
idx := i + 1
fieldName := fmt.Sprintf("file_%d", idx)
sanitized := strings.ReplaceAll(row.Iteration, " ", "")
filename := sanitized + ".html"
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filename)})
data, err := os.ReadFile(row.Path)
if err != nil {
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
return
}
fw, err := createFormFileWithType(mw, fieldName, filename, "text/html")
if err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
if _, err := fw.Write(data); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
rowsMeta = append(rowsMeta, rowMeta{Field: fieldName, IterationName: sanitized})
}
rowsJSON, _ := json.Marshal(rowsMeta)
_ = mw.WriteField("rows", string(rowsJSON))
if err := mw.Close(); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("Uploading %d file%s...", len(valid), pluralS(len(valid)))})
httpReq, _ := http.NewRequest("POST", baseUrl+"/wp-admin/admin-ajax.php", &buf)
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
httpReq.Header.Set("Origin", baseUrl)
httpReq.Header.Set("Referer", baseUrl+"/file-upload/")
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Accept", "*/*")
resp, err := client.Do(httpReq)
if err != nil {
send(map[string]any{"type": "error", "message": "Network error: " + err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
send(map[string]any{"type": "error", "message": fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, truncate(string(respBody), 800))})
return
}
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
send(map[string]any{"type": "error", "message": "Server returned non-JSON:\n" + truncate(string(respBody), 800)})
return
}
var preview string
if data, ok := parsed["data"].(map[string]any); ok {
if files, ok := data["files"].([]any); ok && len(files) > 0 {
params := urlpkg.Values{"theme": {"calcite"}}
nonceRx := regexp.MustCompile(`(?i)\.html?$`)
for i, f := range files {
if fm, ok := f.(map[string]any); ok {
orig, _ := fm["original"].(string)
saved, _ := fm["saved"].(string)
n := nonceRx.ReplaceAllString(orig, "")
params.Set(fmt.Sprintf("n%d", i+1), n)
params.Set(fmt.Sprintf("m%d", i+1), saved)
}
}
preview = baseUrl + "/preview?" + params.Encode()
}
}
if preview == "" {
if v, ok := parsed["preview"].(string); ok {
preview = v
} else if v, ok := parsed["previewURL"].(string); ok {
preview = v
}
}
send(map[string]any{"type": "done", "preview": preview, "raw": parsed, "rawText": string(respBody)})
}
func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) { func ClipboardEndpoint(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Text string `json:"text"` Text string `json:"text"`

View File

@@ -15,6 +15,8 @@ func SettingsSaveEndpoint(w http.ResponseWriter, r *http.Request) {
OriginUrl string `json:"originUrl"` OriginUrl string `json:"originUrl"`
PreviewBase string `json:"previewBase"` PreviewBase string `json:"previewBase"`
SkipExistsCheck bool `json:"skipExistsCheck"` SkipExistsCheck bool `json:"skipExistsCheck"`
WpUsername string `json:"wpUsername"`
WpPassword string `json:"wpPassword"`
} `json:"plec"` } `json:"plec"`
Applovin struct { Applovin struct {
Cookie string `json:"cookie"` Cookie string `json:"cookie"`
@@ -40,6 +42,8 @@ func SettingsSaveEndpoint(w http.ResponseWriter, r *http.Request) {
cfg.Plec.OriginUrl = strings.TrimSpace(req.Plec.OriginUrl) cfg.Plec.OriginUrl = strings.TrimSpace(req.Plec.OriginUrl)
cfg.Plec.PreviewBase = strings.TrimSpace(req.Plec.PreviewBase) cfg.Plec.PreviewBase = strings.TrimSpace(req.Plec.PreviewBase)
cfg.Plec.SkipExistsCheck = req.Plec.SkipExistsCheck cfg.Plec.SkipExistsCheck = req.Plec.SkipExistsCheck
cfg.Plec.WpUsername = strings.TrimSpace(req.Plec.WpUsername)
cfg.Plec.WpPassword = req.Plec.WpPassword
cfg.Applovin.Cookie = strings.TrimSpace(req.Applovin.Cookie) cfg.Applovin.Cookie = strings.TrimSpace(req.Applovin.Cookie)
cfg.Applovin.DelayMs = req.Applovin.DelayMs cfg.Applovin.DelayMs = req.Applovin.DelayMs
cfg.Applovin.RandomizeUserAgent = req.Applovin.RandomizeUserAgent cfg.Applovin.RandomizeUserAgent = req.Applovin.RandomizeUserAgent