9fa84845be
評估 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
315 lines
9.5 KiB
JavaScript
315 lines
9.5 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
});
|
||
}
|
||
})();
|