Files
2meet-data-optimizer/admin/assets/wpdo-migration-wizard.js
T
wpdev 9fa84845be fix: NinjaFirewall 相容性 — 保護 WAF 設定選項不被重導向
評估 NinjaFirewall (WP Edition) 4.9 的相容性,結論是兩者可共存且
不需要開發 AddOn(該外掛全 codebase 零個 apply_filters('nfw_*') /
do_action('nfw_*'),官方相容手段全在部署層)。但核心有一個隱患必須補。

TMDO_Options_Manager::register_settings_group() 以 pre_update_option_{key}
回傳 $old_value,讓選項不再落地 wp_options、改存專屬設定表。而
NinjaFirewall 的 Full WAF 走 auto_prepend_file,在 WordPress 載入前就以
原生 mysqli 直查 wp_options 取 nfw_options / nfw_rules。一旦這些鍵被
重導向,WAF 會讀不到設定而靜默停止防護 —— 不報錯、不寫 log。

nfw_rules 約 77KB 且 autoload=auto,正是 autoload 瘦身最誘人的目標,
因此這條路徑並非理論風險。

Added
- PROTECTED_OPTIONS 常數與註冊守衛(nfw_options / nfw_rules / nfw_checked),
  命中時發出 _doing_it_wrong()。守衛置於方法開頭,全部鍵都被擋時提前返回,
  不再建立空的設定表。
- tests/unit/OptionsManagerProtectedTest.php(4 tests / 10 assertions),
  鎖住「受保護鍵絕不會被掛上 pre_option_* / pre_update_option_* 攔截」。
- docs/WAF-COMPATIBILITY.md:模式差異、symlink 多租戶部署、WP SaaS 開站
  流程與驗證清單、三條開發約束、實測風險矩陣。

Changed
- Migration Wizard 輪詢 500ms → 2s,與四個 stress-test 面板一致。
  原本 2 req/s 打同一 REST endpoint,易觸發 WAF rate-limit 與 bot 偵測。

autoload 最佳化不受影響:optimize_autoload() 只改 autoload 欄位、不刪列,
而 Full WAF 的 SELECT * 不看 autoload。

驗證:591 tests / 1166 assertions 通過,PHPCS 零違規,版本一致性 1.0.2。
dev30 於 Full WAF 與 WP WAF 兩種模式下實測,firewall log 中 TMDO 相關
攔截 0 筆。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdjXAU473eekjPBB8vPVRS
2026-08-15 20:38:09 +08:00

315 lines
9.5 KiB
JavaScript
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.
/**
* WP Data Optimizer — One-click User Migration Wizard frontend.
*
* - Confirms options + checkbox before starting.
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
* - Polls /status every 2s while job is active.
* - Streams log lines with fade-in; live-updates ratio + progress bar.
*
* @since 2.8.0
*/
(function () {
'use strict';
const POLL_INTERVAL_MS = 2000;
const config = window.wpdoMigrationWizard || {};
const i18n = config.i18n || {};
const restUrl = (config.restUrl || '').replace(/\/$/, '');
const nonce = config.nonce || '';
const $ = (id) => document.getElementById(id);
const root = document.querySelector('.wpdo-migration-wizard');
if (!root) return;
const els = {
runPanel: $('wpdo-mw-run-panel'),
progressPanel: $('wpdo-mw-progress-panel'),
donePanel: $('wpdo-mw-done-panel'),
startBtn: $('wpdo-mw-start'),
cancelBtn: $('wpdo-mw-cancel'),
resumeBtn: $('wpdo-mw-resume'),
resetBtn: $('wpdo-mw-reset'),
confirmBox: $('wpdo-mw-confirm-backup'),
optBackup: $('wpdo-mw-opt-backup'),
optStrict: $('wpdo-mw-opt-strict'),
opt24h: $('wpdo-mw-opt-24h'),
optAsync: $('wpdo-mw-opt-async'),
optDryRun: $('wpdo-mw-opt-dryrun'),
progressFill: $('wpdo-mw-progress-fill'),
progressPct: $('wpdo-mw-progress-pct'),
progressTitle: $('wpdo-mw-progress-title'),
phaseName: $('wpdo-mw-current-phase-name'),
elapsed: $('wpdo-mw-elapsed'),
liveRatio: $('wpdo-mw-live-ratio-text'),
log: $('wpdo-mw-log'),
ratio: $('wpdo-mw-ratio'),
residue: $('wpdo-mw-residue'),
usermeta: $('wpdo-mw-usermeta'),
doneSummary: $('wpdo-mw-done-summary'),
};
let pollTimer = null;
let startedAt = 0;
let elapsedTimer = null;
function show(panel) {
[els.runPanel, els.progressPanel, els.donePanel].forEach((p) => {
if (!p) return;
if (p === panel) {
p.removeAttribute('hidden');
} else {
p.setAttribute('hidden', '');
}
});
}
function fetchJson(path, options = {}) {
const url = restUrl + path;
const opts = Object.assign(
{
method: 'GET',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
},
options
);
return fetch(url, opts).then(async (r) => {
let body;
try {
body = await r.json();
} catch (e) {
body = null;
}
return { ok: r.ok, status: r.status, body };
});
}
// ── Start handling ───────────────────────────────────────────────────
if (els.confirmBox && els.startBtn) {
els.confirmBox.addEventListener('change', () => {
els.startBtn.disabled = !els.confirmBox.checked;
});
}
if (els.startBtn) {
els.startBtn.addEventListener('click', () => {
if (!confirm(i18n.confirmStart || 'Start migration?')) return;
els.startBtn.disabled = true;
els.startBtn.textContent = '…';
const opts = {
auto_backup: els.optBackup ? !!els.optBackup.checked : true,
verify_strict: els.optStrict ? !!els.optStrict.checked : true,
verify_24h: els.opt24h ? !!els.opt24h.checked : false,
force_async: els.optAsync ? !!els.optAsync.checked : false,
dry_run: els.optDryRun ? !!els.optDryRun.checked : false,
};
fetchJson('/migration/start', {
method: 'POST',
body: JSON.stringify(opts),
}).then((res) => {
if (!res.ok) {
if (res.body && res.body.reason === 'nothing_to_do') {
alert(i18n.nothingToDo);
els.startBtn.disabled = false;
els.startBtn.textContent = '🚀';
return;
}
alert((res.body && res.body.error) || 'Start failed');
els.startBtn.disabled = false;
els.startBtn.textContent = '🚀';
return;
}
startedAt = Date.now();
show(els.progressPanel);
startElapsedTimer();
startPolling();
});
});
}
// ── Cancel & Resume ──────────────────────────────────────────────────
if (els.cancelBtn) {
els.cancelBtn.addEventListener('click', () => {
if (!confirm(i18n.confirmCancel || 'Cancel?')) return;
els.cancelBtn.disabled = true;
fetchJson('/migration/cancel', { method: 'POST' }).then(() => {
stopPolling();
stopElapsedTimer();
setTimeout(() => location.reload(), 600);
});
});
}
if (els.resumeBtn) {
els.resumeBtn.addEventListener('click', () => {
els.resumeBtn.disabled = true;
fetchJson('/migration/resume', { method: 'POST' }).then((res) => {
if (res.ok) {
els.resumeBtn.setAttribute('hidden', '');
els.cancelBtn.disabled = false;
startPolling();
} else {
alert((res.body && res.body.error) || 'Resume failed');
els.resumeBtn.disabled = false;
}
});
});
}
if (els.resetBtn) {
els.resetBtn.addEventListener('click', () => location.reload());
}
// ── Polling ──────────────────────────────────────────────────────────
function startPolling() {
if (pollTimer) return;
const tick = () => {
fetchJson('/migration/status').then((res) => {
if (!res.ok || !res.body) {
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
return;
}
renderStatus(res.body);
const state = res.body.state;
if (state === 'running') {
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
} else if (state === 'completed') {
stopPolling();
stopElapsedTimer();
renderDone(res.body);
} else if (state === 'failed') {
stopPolling();
stopElapsedTimer();
renderFailed(res.body);
} else if (state === 'cancelled' || state === 'idle') {
stopPolling();
stopElapsedTimer();
}
});
};
tick();
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
}
function startElapsedTimer() {
if (elapsedTimer) return;
elapsedTimer = setInterval(() => {
if (!els.elapsed) return;
const sec = (Date.now() - startedAt) / 1000;
els.elapsed.textContent = sec.toFixed(1) + 's';
}, 100);
}
function stopElapsedTimer() {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
}
// ── Render ───────────────────────────────────────────────────────────
function renderStatus(s) {
if (els.progressFill) {
els.progressFill.style.width = (s.overall_progress || 0) + '%';
}
if (els.progressPct) {
els.progressPct.textContent = (s.overall_progress || 0) + '%';
}
if (els.phaseName) {
els.phaseName.textContent = s.phase || '';
}
if (els.liveRatio && s.metrics) {
els.liveRatio.textContent =
'1:' + (s.metrics.ratio_start || '?') +
' → 1:' + (s.metrics.ratio_now || '?');
}
// Update top metrics live too
if (els.ratio && s.metrics && s.metrics.ratio_now != null) {
els.ratio.textContent = '1:' + s.metrics.ratio_now;
}
if (els.residue && s.metrics && s.metrics.eav_rows_now != null) {
els.residue.textContent = String(s.metrics.eav_rows_now);
}
updateLog(s.log || []);
}
let lastLogLength = 0;
function updateLog(lines) {
if (!els.log) return;
if (lines.length === lastLogLength) return;
els.log.textContent = lines.join('\n');
els.log.scrollTop = els.log.scrollHeight;
lastLogLength = lines.length;
// Apply a subtle highlight on the last line via CSS animation
els.log.classList.remove('wpdo-mw-log-flash');
// Force reflow so re-adding the class re-triggers the animation
// eslint-disable-next-line no-unused-expressions
void els.log.offsetWidth;
els.log.classList.add('wpdo-mw-log-flash');
}
function renderDone(s) {
show(els.donePanel);
if (!els.doneSummary) return;
const m = s.metrics || {};
const elapsed = ((s.completed_at || 0) - (s.started_at || 0));
els.doneSummary.innerHTML = '';
const lines = [
['ratio', '1:' + (m.ratio_start || '?') + ' → 1:' + (m.ratio_now || '?')],
['EAV 殘留', (m.eav_rows_start || 0) + ' → ' + (m.eav_rows_now || 0)],
['mode', (m.mode_start || '?') + ' → aeav_only'],
['耗時', elapsed + ' 秒'],
['備份', s.backup_path || '(無)'],
];
lines.forEach(([label, value]) => {
const div = document.createElement('div');
div.className = 'wpdo-mw-done-line';
const lab = document.createElement('span'); lab.textContent = label + '';
const val = document.createElement('strong'); val.textContent = value;
div.append(lab, val);
els.doneSummary.appendChild(div);
});
}
function renderFailed(s) {
els.progressTitle.textContent = '✗ 失敗 — ' + (i18n.failedRetry || '請 Resume 或 Cancel');
els.progressTitle.classList.add('wpdo-mw-failed');
if (els.resumeBtn) {
els.resumeBtn.removeAttribute('hidden');
els.resumeBtn.disabled = false;
}
}
// ── On-load: if a job is already running, hop into progress mode ────
const initialState = root.getAttribute('data-state');
if (initialState === 'running' || initialState === 'paused') {
startedAt = Date.now();
show(els.progressPanel);
startElapsedTimer();
startPolling();
} else if (initialState === 'failed') {
show(els.progressPanel);
renderFailed({ state: 'failed' });
} else if (initialState === 'completed') {
// Already shown via PHP hidden flag — but populate summary if available
fetchJson('/migration/status').then((res) => {
if (res.ok && res.body && res.body.state === 'completed') {
renderDone(res.body);
}
});
}
})();