679 lines
23 KiB
Go
679 lines
23 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
urlpkg "net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
func PlecPage(w http.ResponseWriter, r *http.Request) {
|
||
body := `
|
||
<header class="tool-header">
|
||
<h2 class="tool-title">PLEC Upload</h2>
|
||
<p class="tool-description">Pick HTML files, give each an iteration name, then upload them to PLEC.</p>
|
||
</header>
|
||
|
||
<section class="tool-panel">
|
||
<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="control-group">
|
||
<div id="dropZone" class="drop-zone">
|
||
<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 class="drop-zone-text">or drop HTML files or folders here</div>
|
||
</div>
|
||
<div id="emptySelection" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||
<div id="rowsPanel" class="results-panel is-hidden" style="margin-top:8px;">
|
||
<table id="rows" class="data-table">
|
||
<thead><tr><th>Filename</th><th style="width:240px;">Iteration Name</th><th style="width:44px;"></th></tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="action-row">
|
||
<button id="upload">Upload</button>
|
||
<button id="openResult" class="secondary" disabled>Open</button>
|
||
<button id="copyResult" class="secondary" disabled>Copy Link</button>
|
||
</div>
|
||
<div id="status" class="status-panel"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.row-error { color:#f48771; font-size:12px; margin-top:4px; }
|
||
#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>
|
||
|
||
<script>
|
||
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 rows = [];
|
||
let page = 0;
|
||
let previewUrl = '';
|
||
const tbody = document.querySelector('#rows tbody');
|
||
const statusEl = document.getElementById('status');
|
||
const rowsPanel = document.getElementById('rowsPanel');
|
||
const emptySelection = document.getElementById('emptySelection');
|
||
const openResultBtn = document.getElementById('openResult');
|
||
const copyResultBtn = document.getElementById('copyResult');
|
||
|
||
function parseIterationName(filename) {
|
||
const base = filename.replace(/\.html?$/i, '');
|
||
const tokens = base.split('_');
|
||
for (const t of tokens) {
|
||
if (/^(full|\d+clk|\d+s(?:ec)?)$/i.test(t)) return t.toLowerCase();
|
||
}
|
||
return base;
|
||
}
|
||
|
||
function escapeHtml(s){
|
||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
function addFiles(files) {
|
||
const existing = new Set(rows.map(r => r.path));
|
||
const before = rows.length;
|
||
(files || []).forEach(f => {
|
||
if (existing.has(f.path)) return;
|
||
rows.push({ id:'r' + (nextId++), path:f.path, name:f.name, iteration:parseIterationName(f.name), error:'' });
|
||
existing.add(f.path);
|
||
});
|
||
const added = rows.length - before;
|
||
if (added > 1) {
|
||
const batch = rows.slice(rows.length - added);
|
||
const first = batch[0].iteration;
|
||
if (first && batch.every(r => r.iteration === first)) {
|
||
batch.forEach((r, i) => r.iteration = String(i + 1).padStart(2, '0') + '_' + first);
|
||
}
|
||
}
|
||
page = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||
renderRows();
|
||
}
|
||
async function readJSONStream(response, handle) {
|
||
if (!response.ok || !response.body) throw new Error('Request failed.');
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
let newline;
|
||
while ((newline = buffer.indexOf('\n')) >= 0) {
|
||
const line = buffer.slice(0, newline);
|
||
buffer = buffer.slice(newline + 1);
|
||
if (line.trim()) handle(JSON.parse(line));
|
||
}
|
||
}
|
||
if (buffer.trim()) handle(JSON.parse(buffer));
|
||
}
|
||
function renderRows() {
|
||
const oldPager = document.getElementById('selectedPager');
|
||
if (oldPager) oldPager.remove();
|
||
rowsPanel.classList.toggle('is-hidden', rows.length === 0);
|
||
emptySelection.classList.toggle('is-hidden', rows.length !== 0);
|
||
if (!rows.length) { tbody.innerHTML = ''; return; }
|
||
const maxPage = Math.max(0, Math.ceil(rows.length / PAGE_SIZE) - 1);
|
||
page = Math.min(page, maxPage);
|
||
const start = page * PAGE_SIZE;
|
||
const pageRows = rows.slice(start, start + PAGE_SIZE);
|
||
tbody.innerHTML = pageRows.map((row, offset) => {
|
||
const i = start + offset;
|
||
return '<tr data-id="' + row.id + '">' +
|
||
'<td><span class="mono wrap">' + escapeHtml(row.name) + '</span><div class="row-error">' + escapeHtml(row.error || '') + '</div></td>' +
|
||
'<td><input type="text" data-iter data-index="' + i + '" value="' + escapeHtml(row.iteration) + '" /></td>' +
|
||
'<td><button class="remove-btn danger" data-remove="' + i + '" title="Remove">×</button></td>' +
|
||
'</tr>';
|
||
}).join('');
|
||
tbody.querySelectorAll('[data-iter]').forEach(input => {
|
||
input.addEventListener('input', () => rows[Number(input.dataset.index)].iteration = input.value);
|
||
});
|
||
tbody.querySelectorAll('[data-remove]').forEach(btn => {
|
||
btn.addEventListener('click', () => { rows.splice(Number(btn.dataset.remove), 1); renderRows(); });
|
||
});
|
||
if (rows.length > PAGE_SIZE) {
|
||
const pager = document.createElement('div');
|
||
pager.id = 'selectedPager';
|
||
pager.className = 'pager';
|
||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (page === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||
'<span class="file-name">Page ' + (page + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||
'<button class="secondary" id="nextPage"' + (page === maxPage ? ' disabled' : '') + '>Next</button>';
|
||
rowsPanel.after(pager);
|
||
pager.querySelector('#prevPage').addEventListener('click', () => { page--; renderRows(); });
|
||
pager.querySelector('#nextPage').addEventListener('click', () => { page++; renderRows(); });
|
||
}
|
||
}
|
||
function setPreview(url) {
|
||
previewUrl = url || '';
|
||
openResultBtn.disabled = !previewUrl;
|
||
copyResultBtn.disabled = !previewUrl;
|
||
}
|
||
setupDropZone('dropZone', statusEl, (j) => {
|
||
addFiles(j.files || []);
|
||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||
});
|
||
|
||
document.getElementById('pickFolder').addEventListener('click', async () => {
|
||
const res = await fetch('/api/plec/pickFolder', { method: 'POST' });
|
||
const j = await res.json();
|
||
addFiles(j.files || []);
|
||
});
|
||
document.getElementById('pickFiles').addEventListener('click', async () => {
|
||
const res = await fetch('/api/plec/pick', { method: 'POST' });
|
||
const j = await res.json();
|
||
addFiles(j.files || []);
|
||
});
|
||
document.getElementById('clear').addEventListener('click', () => {
|
||
rows = [];
|
||
page = 0;
|
||
setPreview('');
|
||
statusEl.textContent = '';
|
||
statusEl.classList.remove('is-busy');
|
||
renderRows();
|
||
});
|
||
openResultBtn.addEventListener('click', () => {
|
||
if (previewUrl) fetch('/api/open', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url: previewUrl }) });
|
||
});
|
||
copyResultBtn.addEventListener('click', () => {
|
||
if (previewUrl) fetch('/api/clipboard', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ text: previewUrl }) });
|
||
});
|
||
|
||
document.getElementById('upload').addEventListener('click', async () => {
|
||
rows.forEach(row => row.error = '');
|
||
renderRows();
|
||
statusEl.textContent = '';
|
||
statusEl.classList.remove('is-busy');
|
||
setPreview('');
|
||
if (rows.length === 0) {
|
||
statusEl.innerHTML = '<span class="err">Select at least one file.</span>';
|
||
return;
|
||
}
|
||
statusEl.textContent = 'Uploading...';
|
||
statusEl.classList.add('is-busy');
|
||
let j = null;
|
||
try {
|
||
const res = await fetch('/api/plec/upload', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ rows, server: serverSelect.value }),
|
||
});
|
||
await readJSONStream(res, (msg) => {
|
||
if (msg.type === 'status') {
|
||
statusEl.textContent = msg.message;
|
||
statusEl.classList.add('is-busy');
|
||
} else if (msg.type === 'done') {
|
||
j = msg;
|
||
} else if (msg.type === 'error') {
|
||
j = { error: msg.message };
|
||
} else if (msg.type === 'rowErrors') {
|
||
j = { rowErrors: msg.rowErrors };
|
||
}
|
||
});
|
||
} catch (e) {
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="err">' + (e.message || e) + '</span>';
|
||
return;
|
||
}
|
||
if (!j) { statusEl.classList.remove('is-busy'); statusEl.innerHTML = '<span class="err">No response.</span>'; return; }
|
||
if (j.rowErrors) {
|
||
statusEl.classList.remove('is-busy');
|
||
for (const re of j.rowErrors) {
|
||
const row = rows.find(r => r.id === re.id);
|
||
if (row) row.error = re.message;
|
||
}
|
||
renderRows();
|
||
statusEl.innerHTML = '<span class="err">Fix row errors and retry.</span>';
|
||
return;
|
||
}
|
||
if (j.error) {
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||
return;
|
||
}
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="ok">Upload complete.</span>';
|
||
setPreview(j.preview);
|
||
});
|
||
|
||
renderRows();
|
||
</script>
|
||
`
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_, _ = w.Write([]byte(Page("/plec", "PLEC Upload", body)))
|
||
}
|
||
|
||
func PlecPick(w http.ResponseWriter, r *http.Request) {
|
||
cfg := LoadConfig()
|
||
picked := PickFiles(
|
||
"Select HTML",
|
||
[]FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}},
|
||
true, cfg.LastPickDir,
|
||
)
|
||
if len(picked) == 0 {
|
||
writeJSON(w, map[string]any{"files": []any{}})
|
||
return
|
||
}
|
||
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 PlecPickFolder(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})
|
||
}
|
||
|
||
type plecRow struct {
|
||
ID string `json:"id"`
|
||
Path string `json:"path"`
|
||
Iteration string `json:"iteration"`
|
||
}
|
||
|
||
func PlecUpload(w http.ResponseWriter, r *http.Request) {
|
||
send := newJSONStream(w)
|
||
var req struct {
|
||
Rows []plecRow `json:"rows"`
|
||
Server string `json:"server"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
send(map[string]any{"type": "error", "message": err.Error()})
|
||
return
|
||
}
|
||
cfg := LoadConfig().Plec
|
||
|
||
type rowErr struct {
|
||
ID string `json:"id"`
|
||
Message string `json:"message"`
|
||
}
|
||
var rowErrors []rowErr
|
||
var valid []plecRow
|
||
htmlRx := regexp.MustCompile(`(?i)\.html?$`)
|
||
|
||
for _, row := range req.Rows {
|
||
if row.Path == "" || !fileExists(row.Path) {
|
||
rowErrors = append(rowErrors, rowErr{row.ID, "File missing"})
|
||
} else if strings.TrimSpace(row.Iteration) == "" {
|
||
rowErrors = append(rowErrors, rowErr{row.ID, "Iteration name required"})
|
||
} else if !htmlRx.MatchString(row.Path) {
|
||
rowErrors = append(rowErrors, rowErr{row.ID, "Must be .html"})
|
||
} else {
|
||
valid = append(valid, row)
|
||
}
|
||
}
|
||
if len(rowErrors) > 0 {
|
||
send(map[string]any{"type": "rowErrors", "rowErrors": rowErrors})
|
||
return
|
||
}
|
||
if len(valid) == 0 {
|
||
send(map[string]any{"type": "error", "message": "Nothing to upload."})
|
||
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")
|
||
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
for i, row := range valid {
|
||
idx := i + 1
|
||
send(map[string]any{"type": "status", "message": fmt.Sprintf("Preparing %d/%d %s", idx, len(valid), filepath.Base(row.Path))})
|
||
data, err := os.ReadFile(row.Path)
|
||
if err != nil {
|
||
send(map[string]any{"type": "error", "message": "Read failed: " + err.Error()})
|
||
return
|
||
}
|
||
stampedName := appendTimestamp(filepath.Base(row.Path), stamp)
|
||
fw, err := createFormFileWithType(mw, fmt.Sprintf("fileUpload_%d", idx), stampedName, "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
|
||
}
|
||
iter := encodeURICompatible(strings.ReplaceAll(row.Iteration, " ", ""))
|
||
if err := mw.WriteField(fmt.Sprintf("iterationNameUpload_%d", idx), iter); err != nil {
|
||
send(map[string]any{"type": "error", "message": err.Error()})
|
||
return
|
||
}
|
||
}
|
||
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, err := http.NewRequest("POST", cfg.UploadUrl, &buf)
|
||
if err != nil {
|
||
send(map[string]any{"type": "error", "message": err.Error()})
|
||
return
|
||
}
|
||
httpReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||
httpReq.Header.Set("Origin", cfg.OriginUrl)
|
||
httpReq.Header.Set("Referer", cfg.OriginUrl+"/")
|
||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (HPL-Toolbox-Standalone)")
|
||
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||
httpReq.Header.Set("Accept", "*/*")
|
||
|
||
resp, err := http.DefaultClient.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 any
|
||
if v, ok := parsed["preview"]; ok && v != nil {
|
||
preview = v
|
||
} else if v, ok := parsed["previewURL"]; ok {
|
||
preview = v
|
||
}
|
||
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) {
|
||
var req struct {
|
||
Text string `json:"text"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||
return
|
||
}
|
||
if err := CopyToClipboard(req.Text); err != nil {
|
||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, map[string]any{"ok": true})
|
||
}
|
||
|
||
func OpenEndpoint(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
URL string `json:"url"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||
return
|
||
}
|
||
OpenInBrowser(req.URL)
|
||
writeJSON(w, map[string]any{"ok": true})
|
||
}
|
||
|
||
func fileExists(p string) bool {
|
||
_, err := os.Stat(p)
|
||
return err == nil
|
||
}
|
||
|
||
func appendTimestamp(filename, stamp string) string {
|
||
ext := filepath.Ext(filename)
|
||
base := filename
|
||
if ext != "" {
|
||
base = filename[:len(filename)-len(ext)]
|
||
}
|
||
return base + "_" + stamp + ext
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n]
|
||
}
|
||
|
||
// createFormFileWithType is like multipart.Writer.CreateFormFile but lets us
|
||
// override the Content-Type (CreateFormFile hardcodes "application/octet-stream").
|
||
func createFormFileWithType(mw *multipart.Writer, field, filename, contentType string) (io.Writer, error) {
|
||
h := make(map[string][]string)
|
||
h["Content-Disposition"] = []string{
|
||
fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename),
|
||
}
|
||
h["Content-Type"] = []string{contentType}
|
||
return mw.CreatePart(h)
|
||
}
|
||
|
||
// encodeURICompatible mimics JS encodeURI() — keeps A-Za-z0-9 and these
|
||
// reserved/mark chars: ;,/?:@&=+$-_.!~*'()#
|
||
func encodeURICompatible(s string) string {
|
||
keep := func(c byte) bool {
|
||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||
return true
|
||
}
|
||
switch c {
|
||
case ';', ',', '/', '?', ':', '@', '&', '=', '+', '$',
|
||
'-', '_', '.', '!', '~', '*', '\'', '(', ')', '#':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
var b strings.Builder
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if keep(c) {
|
||
b.WriteByte(c)
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("%%%02X", c))
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|