-
Order
-
Change the order in which the characters appear in your in-game character selection screen.
+
Order Character Screen
+
Change the order in which your characters appear in the in-game selection screen by dragging them into your preferred position.
+
+
+
+
+ = $accBanPerma ? 'Banned' : esc_html('Banned for: ' . $this->formatDuration($accBanRemaining)) ?>
+
+ Ends: = esc_html($this->formatDate($accBanRow['unbandate'])) ?> at = esc_html($this->formatTime($accBanRow['unbandate'])) ?>
+
+
+
+
+
+ = $mutePending ? esc_html('Muted for ' . $this->formatDuration($muteRemaining)) : esc_html('Muted for: ' . $this->formatDuration($muteRemaining)) ?>
+ Starts upon login
+
+ Ends: = esc_html($this->formatDate($mutetime)) ?> at = esc_html($this->formatTime($mutetime)) ?>
+
+
+
+
+
-
+ $("#acore-characters-order").sortable({
+ update: function(event, ui) {
+ var order = [];
+ $("#acore-characters-order li").each(function(index) {
+ var input = $(this).find("input[name='characterorder[]']");
+ order.push(input.val());
+ $(this).find(".acore-char-pos").text(index + 1);
+ });
+ }
+ });
+ $("#acore-characters-order").disableSelection();
+
+ var acorePdumpRevision = = json_encode($serverRevision ?: '') ?>;
+ var acorePdumpRevisionUrl = = json_encode($serverRevisionUrl ?: '') ?>;
+ var acorePdumpBugReportUrl = = json_encode($bugReportUrl ?: '') ?>;
+ var acorePdumpRestBase = = json_encode(get_rest_url(null, ACORE_SLUG . '/v1/pdump/')) ?>;
+ var acorePdumpAllUrl = = json_encode(get_rest_url(null, ACORE_SLUG . '/v1/pdump/all')) ?>;
+ var acorePdumpNonce = = json_encode(wp_create_nonce('wp_rest')) ?>;
+ var acorePdumpModal = document.getElementById('acore-pdump-modal');
+ var acorePdumpBody = document.getElementById('acore-pdump-modal-body');
+ var acorePdumpConfirm = document.getElementById('acore-pdump-confirm');
+ var acorePdumpOnConfirm = null;
+
+ function acorePdumpFilename(order, level, race, cls) {
+ var now = new Date();
+ var pad = function(n) { return String(n).padStart(2, '0'); };
+ return order + '_' + level + '_' + race + '_' + cls + '_'
+ + pad(now.getDate()) + '_' + pad(now.getMonth() + 1) + '_' + now.getFullYear() + '_'
+ + pad(now.getHours()) + '_' + pad(now.getMinutes()) + '_' + pad(now.getSeconds()) + '.dump';
+ }
+
+ function acorePdumpZipFilename() {
+ var now = new Date();
+ var pad = function(n) { return String(n).padStart(2, '0'); };
+ return 'dump_all_' + pad(now.getDate()) + '_' + pad(now.getMonth() + 1) + '_' + now.getFullYear() + '_'
+ + pad(now.getHours()) + '_' + pad(now.getMinutes()) + '_' + pad(now.getSeconds()) + '.zip';
+ }
+
+ function acorePdumpRevisionLink() {
+ if (!acorePdumpRevision) return '(unknown revision)';
+ if (acorePdumpRevisionUrl) {
+ return '
' + acorePdumpRevision + '';
+ }
+ return acorePdumpRevision;
+ }
+
+ var acorePdumpError = document.getElementById('acore-pdump-error');
+ var acorePdumpErrorIntro = document.getElementById('acore-pdump-error-intro');
+ var acorePdumpErrorDetails = document.getElementById('acore-pdump-error-details');
+ var acorePdumpErrorMsg = document.getElementById('acore-pdump-error-msg');
+
+ function acoreShowPdumpModal(message, onConfirm) {
+ acorePdumpBody.innerHTML = message;
+ acorePdumpError.style.display = 'none';
+ acorePdumpErrorIntro.innerHTML = '';
+ acorePdumpErrorMsg.textContent = '';
+ acorePdumpErrorDetails.style.display = 'none';
+ acorePdumpErrorDetails.removeAttribute('open');
+ acorePdumpConfirm.style.display = '';
+ acorePdumpOnConfirm = onConfirm;
+ acorePdumpModal.style.display = 'flex';
+ }
+
+ function acoreShowPdumpError(msg, detail) {
+ acorePdumpBody.innerHTML = '';
+ // Show the human-readable message + technical detail (if any) in the
+ acorePdumpErrorMsg.textContent = detail ? (msg + '\n\n' + detail) : msg;
+
+ if (acorePdumpBugReportUrl) {
+ acorePdumpErrorIntro.innerHTML =
+ 'There was an error, the PDUMP was not successful, it seems to be a bug, please report it on '
+ + 'GitHub.';
+ acorePdumpErrorDetails.style.display = 'block';
+ } else {
+ acorePdumpErrorIntro.textContent =
+ 'There was an error, and it seems there is no URL for you to report to. '
+ + 'Talk to the administrator to fix this, in AzerothCore → Tools → PDUMP Bug Report URL.';
+ acorePdumpErrorDetails.style.display = 'none';
+ }
+
+ acorePdumpError.style.display = 'block';
+ acorePdumpConfirm.style.display = 'none';
+ acorePdumpOnConfirm = null;
+ acorePdumpModal.style.display = 'flex';
+ }
+
+ function acoreClosePdumpModal() {
+ acorePdumpModal.style.display = 'none';
+ acorePdumpOnConfirm = null;
+ }
+
+ function acoreDownloadDump(guid, order, level, race, cls) {
+ fetch(acorePdumpRestBase + guid, {
+ headers: { 'X-WP-Nonce': acorePdumpNonce }
+ }).then(function(resp) {
+ if (!resp.ok) {
+ return resp.json().then(function(body) {
+ var data = (body && body.data) ? body.data : body;
+ var msg = (data && data.message) ? data.message : 'Export failed (HTTP ' + resp.status + ').';
+ var detail = (data && data.detail) ? data.detail : null;
+ var err = new Error(msg);
+ err.detail = detail;
+ throw err;
+ });
+ }
+ return resp.blob();
+ }).then(function(blob) {
+ var filename = acorePdumpFilename(order, level, race, cls);
+ var url = URL.createObjectURL(blob);
+ var link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ setTimeout(function() { URL.revokeObjectURL(url); }, 10000);
+ }).catch(function(err) {
+ acoreShowPdumpError(
+ err && err.message ? err.message : String(err),
+ err && err.detail ? err.detail : null
+ );
+ });
+ }
+
+ function acoreDownloadAll(chars) {
+ fetch(acorePdumpAllUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-WP-Nonce': acorePdumpNonce
+ },
+ body: JSON.stringify({ characters: chars })
+ }).then(function(resp) {
+ if (!resp.ok) {
+ return resp.json().then(function(body) {
+ var data = (body && body.data) ? body.data : body;
+ var msg = (data && data.message) ? data.message : 'Export failed (HTTP ' + resp.status + ').';
+ var detail = (data && data.detail) ? data.detail : null;
+ var err = new Error(msg);
+ err.detail = detail;
+ throw err;
+ });
+ }
+ return resp.blob();
+ }).then(function(blob) {
+ var filename = acorePdumpZipFilename();
+ var url = URL.createObjectURL(blob);
+ var link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ setTimeout(function() { URL.revokeObjectURL(url); }, 10000);
+ }).catch(function(err) {
+ acoreShowPdumpError(
+ err && err.message ? err.message : String(err),
+ err && err.detail ? err.detail : null
+ );
+ });
+ }
+
+ document.getElementById('acore-pdump-cancel').addEventListener('click', acoreClosePdumpModal);
+ acorePdumpConfirm.addEventListener('click', function() {
+ if (typeof acorePdumpOnConfirm === 'function') acorePdumpOnConfirm();
+ acoreClosePdumpModal();
+ });
+ acorePdumpModal.addEventListener('click', function(e) {
+ if (e.target === acorePdumpModal) acoreClosePdumpModal();
+ });
+
+ $(document).on('click', '.acore-export-btn', function() {
+ var guid = $(this).data('char-guid');
+ var name = $(this).data('char-name');
+ var order = $(this).data('char-order');
+ var level = $(this).data('char-level');
+ var race = $(this).data('char-race');
+ var cls = $(this).data('char-class');
+ var upper = name.toUpperCase();
+ var msg = 'You\'re about to PDUMP a.k.a make a copy of your character ' + upper + ' that can be used in AzerothCore. '
+ + 'This will NOT COPY any custom contents, for example modules like Transmog or Custom Items like Physical Costumes. '
+ + '
This Character Dump was extracted from this version:
' + acorePdumpRevisionLink()
+ + '
The following information will be anonymised or omitted from the dump: '
+ + 'CHARACTER NAME • POSITION • HEARTHSTONE LOCATION • '
+ + 'GOLD • TIMESTAMPS • ONLINE STATUS • '
+ + 'ACHIEVEMENT DATES • MAIL CONTENTS & SENDER • '
+ + 'ITEM CREATOR/GIFTER • AURA CASTER • '
+ + 'EQUIPMENT SET NAMES • CUSTOM CHAT CHANNELS • '
+ + 'CHARACTER & ACCOUNT IDs (replaced with random values).';
+ acoreShowPdumpModal(msg, function() {
+ acoreDownloadDump(guid, order, level, race, cls);
+ });
+ });
+
+ $(document).on('click', '.acore-export-all-btn', function() {
+ var chars = [];
+ $('.acore-export-btn').each(function() {
+ chars.push({
+ guid: $(this).data('char-guid'),
+ name: $(this).data('char-name'),
+ order: $(this).data('char-order'),
+ level: $(this).data('char-level'),
+ race: $(this).data('char-race'),
+ 'class': $(this).data('char-class')
+ });
+ });
+ var nameList = chars.map(function(c) { return '' + c.name.toUpperCase() + ''; }).join(', ');
+ var msg = 'You\'re about to PDUMP a.k.a make a copy of ALL your characters: ' + nameList + '. '
+ + 'This will NOT COPY any custom contents, for example modules like Transmog or Custom Items like Physical Costumes. '
+ + '
This Character Dump was extracted from this version:
' + acorePdumpRevisionLink()
+ + '
The following information will be anonymised or omitted from the dump: '
+ + 'CHARACTER NAME • POSITION • HEARTHSTONE LOCATION • '
+ + 'GOLD • TIMESTAMPS • ONLINE STATUS • '
+ + 'ACHIEVEMENT DATES • MAIL CONTENTS & SENDER • '
+ + 'ITEM CREATOR/GIFTER • AURA CASTER • '
+ + 'EQUIPMENT SET NAMES • CUSTOM CHAT CHANNELS • '
+ + 'CHARACTER & ACCOUNT IDs (replaced with random values).';
+ acoreShowPdumpModal(msg, function() {
+ acoreDownloadAll(chars);
+ });
+ });
+ });
+
acore_db_world_name;
$itemQuery = "SELECT mi.`mail_id`, ii.`itemEntry`, ii.`count`,
- it.`name` AS item_name
+ it.`name` AS item_name, it.`Quality` AS item_quality
FROM `mail_items` mi
JOIN `item_instance` ii ON mi.`item_guid` = ii.`guid`
JOIN `$worldDb`.`item_template` it ON ii.`itemEntry` = it.`entry`
diff --git a/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php b/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php
index 5a882e5c2..c75b11169 100644
--- a/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php
+++ b/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php
@@ -2,6 +2,8 @@
namespace ACore\Components\MailReturnMenu;
+use ACore\Utils\AcoreCharColors;
+
class MailReturnView
{
@@ -17,58 +19,80 @@ public function getMailReturnRender($chars)
ob_start();
wp_enqueue_style('bootstrap-css', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', array(), '5.1.3');
- wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.1');
+ wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.5');
wp_enqueue_script('bootstrap-js', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js', array(), '5.1.3');
wp_enqueue_script('jquery');
- wp_enqueue_script('acore-mail-return-js', ACORE_URL_PLG . 'web/assets/mail-return/mail-return.js', array('jquery'), null, true);
+ wp_enqueue_script('power-js', 'https://wow.zamimg.com/widgets/power.js', array(), null, false);
+ wp_enqueue_script('acore-mail-return-js', ACORE_URL_PLG . 'web/assets/mail-return/mail-return.js', array('jquery'), '2.3', true);
+ wp_localize_script('acore-mail-return-js', 'mailReturnData', [
+ 'mailsUrl' => rest_url(ACORE_SLUG . '/v1/mail-return/list'),
+ 'returnUrl' => rest_url(ACORE_SLUG . '/v1/mail-return'),
+ 'assetsUrl' => ACORE_URL_PLG . 'web/assets/',
+ 'nonce' => wp_create_nonce('wp_rest'),
+ ]);
?>
-
-
-
-
-
Mail Return
-
You can return sent mails that have not yet been read by the recipient in this page. Select the character, the sent mail and hit return.
-
-
-
-
-
-
-
-
Loading...
+
+
+
+
+
+
-
-
Unread Sent Mails
-
-
No unread sent mails found for this character.
+
+
-
-
-
-
-
-
-
+
+
+
+
+ acore_resurrection_scroll != '1') {
+ return false;
+ }
+ $csv = get_option('acore_modules_csv', '');
+ $modules = $csv ? array_map('trim', explode(',', $csv)) : [];
+ return in_array('mod-resurrection-scroll', $modules);
+ }
+
function acore_resurrection_scroll_menu()
{
- if (Opts::I()->acore_resurrection_scroll == '1') {
+ if ($this->isAvailable()) {
add_submenu_page('profile.php', 'Scroll of Resurrection', 'Scroll of Resurrection', 'read', ACORE_SLUG . '-resurrection-scroll', array($this, 'acore_resurrection_scroll_menu_page'));
}
}
@@ -41,6 +51,5 @@ function acore_resurrection_scroll_menu_page()
function resurrection_scroll_menu_init()
{
$menu = ResurrectionScrollMenu::I();
-
add_action('admin_menu', array($menu, 'acore_resurrection_scroll_menu'));
}
diff --git a/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php b/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php
index 345fa33bb..19a69adf4 100644
--- a/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php
+++ b/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php
@@ -16,7 +16,7 @@ public function getRender($data)
ob_start();
wp_enqueue_style('bootstrap-css', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', array(), '5.1.3');
- wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.1');
+ wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.5');
wp_enqueue_script('bootstrap-js', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js', array(), '5.1.3');
// Determine account status
@@ -42,7 +42,7 @@ public function getRender($data)
?>
-
+
Scroll of Resurrection
@@ -66,10 +66,10 @@ public function getRender($data)
Enrolled
-
Eligible
+
Eligible
- Log in to the game server to activate
-
Not Eligible
+
Not Eligible
getAcoreAccountId();
+ if (!$accId) return false;
+ $conn = $services->getAccountEm()->getConnection();
+ $row = $conn->executeQuery('SELECT totp_secret FROM account WHERE id = ?', [$accId])->fetchAssociative();
+ return $row && $row['totp_secret'] !== null;
+ } catch (\Throwable $e) {
+ return false;
+ }
+}
+
+/**
+ * Detect a user-initiated Website 2FA removal (done through the WP 2FA plugin UI)
+ * and log it with the user's IP. Admin removals pre-set the "last seen" flag to
+ * '0', so they are never misattributed here. Runs in the user's own session, so
+ * the only IP ever recorded is the user's - never an administrator's.
+ */
+function acore_2fa_sync_self_removals(int $userId): void {
+ $enabled = acore_website_2fa_enabled($userId);
+ $seen = get_user_meta($userId, 'acore_2fa_ws_seen_enabled', true);
+
+ if ($seen === '') {
+ update_user_meta($userId, 'acore_2fa_ws_seen_enabled', $enabled ? '1' : '0');
+ return;
+ }
+
+ if ($seen === '1' && !$enabled) {
+ $log = get_user_meta($userId, 'acore_2fa_admin_log', true);
+ $log = is_array($log) ? $log : [];
+ $log[] = [
+ 'type' => 'website',
+ 'by' => 'self',
+ 'timestamp' => time(),
+ 'ip' => \ACore\Hooks\User\acore_resolve_client_ip(),
+ ];
+ update_user_meta($userId, 'acore_2fa_admin_log', $log);
+ }
+
+ update_user_meta($userId, 'acore_2fa_ws_seen_enabled', $enabled ? '1' : '0');
+}
+
add_action( 'rest_api_init', function () {
register_rest_route( ACORE_SLUG . '/v1', 'server-info', array(
'methods' => 'GET',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
'callback' => function( $request ) {
- $data = ['message' => ServerInfoApi::serverInfo()];
- return $data;
+ $result = ServerInfoApi::serverInfo();
+ $errorPatterns = [
+ 'could not connect', 'connection refused', 'operation timed out',
+ 'error fetching', 'failed to enable', 'soap fault', 'not configured',
+ 'unable to connect', 'network unreachable',
+ ];
+ foreach ($errorPatterns as $pattern) {
+ if (stripos($result, $pattern) !== false) {
+ error_log('[acore] server-info SOAP error: ' . (is_scalar($result) ? (string) $result : 'non-scalar response'));
+ return new \WP_Error('soap_error', __('Server information is temporarily unavailable.', 'acore-wp-plugin'), ['status' => 503]);
+ }
+ }
+ return ['message' => $result];
+ }
+ ) );
+
+ $defaultRequirements = [['Scroll of Resurrection', 'mod-resurrection-scroll']];
+
+ register_rest_route( ACORE_SLUG . '/v1', 'server-module-requirements', array(
+ 'methods' => 'GET',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( $request ) use ($defaultRequirements) {
+ return ['requirements' => get_option('acore_module_requirements', $defaultRequirements)];
+ }
+ ));
+
+ register_rest_route( ACORE_SLUG . '/v1', 'server-module-requirements', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( $request ) {
+ $data = $request->get_json_params();
+ $reqs = isset($data['requirements']) ? $data['requirements'] : [];
+ $clean = [];
+ foreach ($reqs as $row) {
+ if (is_array($row) && count($row) === 2) {
+ $clean[] = [sanitize_text_field($row[0]), sanitize_text_field($row[1])];
+ }
+ }
+ update_option('acore_module_requirements', $clean);
+ return ['success' => true, 'requirements' => $clean];
+ }
+ ));
+
+ register_rest_route( ACORE_SLUG . '/v1', 'server-modules', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( $request ) {
+ try {
+ $raw = ACoreServices::I()->getServerSoap()->executeCommand('.server debug');
+ } catch (\Throwable $e) {
+ return new \WP_Error('soap_error', $e->getMessage(), ['status' => 503]);
+ }
+ if (!is_string($raw)) {
+ return new \WP_Error('soap_error', __('Unexpected module response from the server.', 'acore-wp-plugin'), ['status' => 503]);
+ }
+ $modules = [];
+ $capturing = false;
+ foreach (explode("\n", $raw) as $line) {
+ $line = trim($line);
+ if (!$capturing) {
+ if (stripos($line, 'List of enabled modules:') !== false) $capturing = true;
+ continue;
+ }
+ if (preg_match('/\b(mod-[a-zA-Z0-9_-]+)/', $line, $m)) $modules[] = $m[1];
+ }
+ $csv = implode(',', $modules);
+ $timestamp = time();
+ update_option('acore_modules_csv', $csv);
+ update_option('acore_modules_refreshed', $timestamp);
+ return ['modules' => $modules, 'csv' => $csv, 'refreshed' => $timestamp];
+ }
+ ) );
+
+ // Admin: check 2FA status for any user
+ register_rest_route( ACORE_SLUG . '/v1', 'admin/2fa-check', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $data = $request->get_json_params();
+ $type = isset($data['type']) ? sanitize_text_field($data['type']) : '';
+ $username = isset($data['username']) ? sanitize_text_field($data['username']) : '';
+ if (!in_array($type, ['website', 'ingame'], true))
+ return new \WP_Error('invalid_type', 'Invalid type.', ['status' => 400]);
+ if ($username === '')
+ return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]);
+ $user = get_user_by('login', $username);
+ if (!$user)
+ return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]);
+
+ $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true);
+ $log = is_array($log) ? $log : [];
+ $lastRemoval = null;
+ foreach (array_reverse($log) as $entry) {
+ if ($entry['type'] === $type) { $lastRemoval = $entry; break; }
+ }
+
+ if ($type === 'website') {
+ $active = acore_website_2fa_enabled($user->ID);
+ $backupCodes = get_user_meta($user->ID, 'wp_2fa_backup_codes', true);
+ $resp = [
+ 'found' => true,
+ 'username' => $user->user_login,
+ 'active' => $active,
+ 'backup_codes' => is_array($backupCodes) ? count($backupCodes) : 0,
+ ];
+ if ($lastRemoval) $resp['last_removal'] = ['date' => wp_date('jS \o\f F, Y \a\t H:i', $lastRemoval['timestamp']), 'by' => $lastRemoval['by'] ?? 'admin', 'staff' => $lastRemoval['staff'] ?? null, 'ip' => $lastRemoval['ip'] ?? null];
+ return $resp;
+ }
+
+ try {
+ $conn = ACoreServices::I()->getAccountEm()->getConnection();
+ $result = $conn->executeQuery('SELECT totp_secret FROM account WHERE username = ?', [strtoupper($username)]);
+ $row = $result->fetchAssociative();
+ if (!$row) return new \WP_Error('no_game_account', 'No game account found for this user.', ['status' => 404]);
+ $resp = ['found' => true, 'username' => $user->user_login, 'active' => $row['totp_secret'] !== null];
+ if ($lastRemoval) $resp['last_removal'] = ['date' => wp_date('jS \o\f F, Y \a\t H:i', $lastRemoval['timestamp']), 'by' => $lastRemoval['by'] ?? 'admin', 'staff' => $lastRemoval['staff'] ?? null, 'ip' => $lastRemoval['ip'] ?? null];
+ return $resp;
+ } catch (\Exception $e) {
+ return new \WP_Error('db_error', 'Database error.', ['status' => 500]);
+ }
+ }
+ ));
+
+ // Admin: remove 2FA for any user and log the action
+ register_rest_route( ACORE_SLUG . '/v1', 'admin/2fa-remove', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $data = $request->get_json_params();
+ $type = isset($data['type']) ? sanitize_text_field($data['type']) : '';
+ $username = isset($data['username']) ? sanitize_text_field($data['username']) : '';
+ if (!in_array($type, ['website', 'ingame'], true))
+ return new \WP_Error('invalid_type', 'Invalid type.', ['status' => 400]);
+ if ($username === '')
+ return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]);
+ $user = get_user_by('login', $username);
+ if (!$user)
+ return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]);
+
+ if ($type === 'website') {
+ delete_user_meta($user->ID, 'wp_2fa_totp_key');
+ delete_user_meta($user->ID, 'wp_2fa_enabled_methods');
+ delete_user_meta($user->ID, 'wp_2fa_backup_methods_enabled');
+ delete_user_meta($user->ID, 'wp_2fa_grace_period_expiry');
+ delete_user_meta($user->ID, 'wp_2fa_user_setup_started_at');
+ delete_user_meta($user->ID, 'wp_2fa_user_authenticated_methods');
+ // Pre-set the user's last-seen flag so the self-removal detector
+ // does not misattribute this admin action to the user.
+ update_user_meta($user->ID, 'acore_2fa_ws_seen_enabled', '0');
+ } else {
+ try {
+ $conn = ACoreServices::I()->getAccountEm()->getConnection();
+ $rows = $conn->executeStatement('UPDATE account SET totp_secret = NULL WHERE username = ?', [strtoupper($username)]);
+ if ($rows === 0) return new \WP_Error('no_game_account', 'No game account found for this user.', ['status' => 404]);
+ } catch (\Exception $e) {
+ return new \WP_Error('db_error', 'Database error.', ['status' => 500]);
+ }
+ }
+
+ $staff = wp_get_current_user();
+ $now = time();
+ $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true);
+ $log = is_array($log) ? $log : [];
+ $log[] = ['type' => $type, 'by' => 'admin', 'timestamp' => $now, 'staff' => $staff->user_login, 'staff_id' => $staff->ID];
+ update_user_meta($user->ID, 'acore_2fa_admin_log', $log);
+ return ['success' => true, 'date' => wp_date('jS \o\f F, Y \a\t H:i', $now), 'staff' => $staff->user_login];
+ }
+ ));
+
+ // Admin: remove a user's WP 2FA backup codes and notify them
+ register_rest_route( ACORE_SLUG . '/v1', 'admin/backup-codes-remove', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $data = $request->get_json_params();
+ $username = isset($data['username']) ? sanitize_text_field($data['username']) : '';
+ if ($username === '')
+ return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]);
+ $user = get_user_by('login', $username);
+ if (!$user)
+ return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]);
+
+ delete_user_meta($user->ID, 'wp_2fa_backup_codes');
+
+ $staff = wp_get_current_user();
+ $now = time();
+ $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true);
+ $log = is_array($log) ? $log : [];
+ $log[] = ['type' => 'backup', 'by' => 'admin', 'timestamp' => $now, 'staff' => $staff->user_login, 'staff_id' => $staff->ID];
+ update_user_meta($user->ID, 'acore_2fa_admin_log', $log);
+
+ return ['success' => true, 'date' => wp_date('jS \o\f F, Y \a\t H:i', $now), 'staff' => $staff->user_login];
+ }
+ ));
+
+ // Admin: look up a user's recorded login IP history (same data the user sees)
+ register_rest_route( ACORE_SLUG . '/v1', 'admin/login-history', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return current_user_can('manage_options'); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $data = $request->get_json_params();
+ $username = isset($data['username']) ? sanitize_text_field($data['username']) : '';
+ $page = max(1, (int) ($data['page'] ?? 1));
+ $perPage = 50;
+ if ($username === '')
+ return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]);
+
+ $user = get_user_by('login', $username);
+ if (!$user)
+ return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]);
+ $all = \ACore\Hooks\User\acore_get_login_history($user->ID, 500);
+
+ $all = is_array($all) ? $all : [];
+ $total = count($all);
+ $offset = ($page - 1) * $perPage;
+ $slice = array_slice($all, $offset, $perPage);
+
+ $history = [];
+ foreach ($slice as $r) {
+ $history[] = [
+ 'ip' => $r['ip_address'] ?? '',
+ 'country' => $r['country'] ?? 'Unknown',
+ 'date' => isset($r['login_at']) ? \ACore\Hooks\User\acore_format_connection_date($r['login_at']) : '',
+ 'where' => (($r['source'] ?? 'website') === 'ingame') ? 'In-game' : 'Website',
+ ];
+ }
+ return [
+ 'found' => true,
+ 'username' => $username,
+ 'history' => $history,
+ 'total' => $total,
+ 'from' => $total ? $offset + 1 : 0,
+ 'to' => $offset + count($slice),
+ 'page' => $page,
+ 'has_more' => ($offset + count($slice)) < $total,
+ ];
+ }
+ ));
+
+ register_rest_route( ACORE_SLUG . '/v1', 'remove-ingame-2fa', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $user = wp_get_current_user();
+ if (!acore_website_2fa_enabled($user->ID))
+ return new \WP_Error('no_website_2fa', __('You must have website 2FA enabled to remove in-game 2FA from here.'), ['status' => 403]);
+
+ // Authorise via a recent panel unlock (TOTP or email) OR a fresh TOTP code.
+ if (!get_transient(acore_2fa_unlock_key($user->ID))) {
+ if (!acore_website_totp_enabled($user->ID))
+ return new \WP_Error('verify_required', __('Please verify your 2FA above before removing in-game 2FA.'), ['status' => 403]);
+ $data = $request->get_json_params();
+ $token = isset($data['token']) ? trim((string) $data['token']) : '';
+ if (!preg_match('/^\d{6}$/', $token))
+ return new \WP_Error('invalid_token', __('Please enter a valid 6-digit code.'), ['status' => 400]);
+ if (!\ACore\Components\ServerInfo\acore_wp2fa_code_is_valid($user->ID, $token))
+ return new \WP_Error('wrong_token', __('Incorrect code. Please try again.'), ['status' => 401]);
+ }
+
+ try {
+ $accId = ACoreServices::I()->getAcoreAccountId();
+ if (!$accId) return new \WP_Error('no_account', __('Could not find your game account.'), ['status' => 404]);
+ $conn = ACoreServices::I()->getAccountEm()->getConnection();
+ $conn->executeStatement('UPDATE account SET totp_secret = NULL WHERE id = ?', [$accId]);
+ return ['success' => true];
+ } catch (\Exception $e) {
+ return new \WP_Error('db_error', __('Database error. Please try again.'), ['status' => 500]);
+ }
+ }
+ ) );
+
+ // User: verify own website 2FA (TOTP) code - used to gate sensitive panels (e.g. backup codes)
+ register_rest_route( ACORE_SLUG . '/v1', 'verify-website-2fa', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $user = wp_get_current_user();
+ if (!acore_website_totp_enabled($user->ID))
+ return new \WP_Error('no_website_2fa', __('Website 2FA is not enabled on your account.'), ['status' => 400]);
+
+ $attemptKey = acore_2fa_attempt_key($user->ID);
+ if ((int) get_transient($attemptKey) >= 5)
+ return new \WP_Error('rate_limited', __('Too many incorrect codes. Please wait a few minutes.', 'acore-wp-plugin'), ['status' => 429]);
+
+ $data = $request->get_json_params();
+ $token = isset($data['token']) ? trim((string) $data['token']) : '';
+ if (!preg_match('/^\d{6}$/', $token))
+ return new \WP_Error('invalid_token', __('Please enter a valid 6-digit code.'), ['status' => 400]);
+ if (!\ACore\Components\ServerInfo\acore_wp2fa_code_is_valid($user->ID, $token)) {
+ set_transient($attemptKey, ((int) get_transient($attemptKey)) + 1, 10 * MINUTE_IN_SECONDS);
+ return new \WP_Error('wrong_token', __('Incorrect code. Please try again.'), ['status' => 401]);
+ }
+
+ delete_transient($attemptKey);
+ // Remember this unlock briefly so page refreshes don't re-prompt for the code.
+ set_transient(acore_2fa_unlock_key($user->ID), time(), 30 * MINUTE_IN_SECONDS);
+
+ return ['success' => true];
+ }
+ ) );
+
+ // User: email an unlock code (for accounts using email-based website 2FA)
+ register_rest_route( ACORE_SLUG . '/v1', 'request-email-2fa', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function() {
+ $user = wp_get_current_user();
+ $rateKey = 'acore_2fa_email_rate_' . $user->ID;
+ if (get_transient($rateKey))
+ return new \WP_Error('rate_limited', __('Please wait before requesting another code.'), ['status' => 429]);
+
+ $primary = get_user_meta($user->ID, 'wp_2fa_enabled_methods', true);
+ $methods = is_array($primary) ? $primary : ($primary !== '' ? [$primary] : []);
+ if (!in_array('email', $methods, true))
+ return new \WP_Error('no_email_2fa', __('Email 2FA is not enabled on your account.'), ['status' => 400]);
+
+ $code = (string) wp_rand(100000, 999999);
+ set_transient('acore_2fa_email_code_' . acore_2fa_unlock_key($user->ID), wp_hash($code), 10 * MINUTE_IN_SECONDS);
+ set_transient($rateKey, true, 60);
+
+ $sent = wp_mail(
+ $user->user_email,
+ __('Your security verification code', 'acore-wp-plugin'),
+ sprintf(__('Your verification code is: %s (valid for 10 minutes).', 'acore-wp-plugin'), $code)
+ );
+ if (!$sent)
+ return new \WP_Error('email_failed', __('Could not send the email. Please try again later.'), ['status' => 500]);
+
+ return ['success' => true];
+ }
+ ) );
+
+ // User: verify an emailed code to unlock sensitive panels (same unlock as TOTP)
+ register_rest_route( ACORE_SLUG . '/v1', 'verify-email-2fa', array(
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $user = wp_get_current_user();
+ $key = 'acore_2fa_email_code_' . acore_2fa_unlock_key($user->ID);
+ $attemptKey = acore_2fa_attempt_key($user->ID);
+ if ((int) get_transient($attemptKey) >= 5)
+ return new \WP_Error('rate_limited', __('Too many incorrect codes. Please wait a few minutes.', 'acore-wp-plugin'), ['status' => 429]);
+
+ $data = $request->get_json_params();
+ $code = isset($data['code']) ? trim((string) $data['code']) : '';
+ if (!preg_match('/^\d{6}$/', $code))
+ return new \WP_Error('invalid_token', __('Please enter a valid 6-digit code.'), ['status' => 400]);
+
+ $stored = get_transient($key);
+ if (!$stored || !hash_equals((string) $stored, wp_hash($code))) {
+ set_transient($attemptKey, ((int) get_transient($attemptKey)) + 1, 10 * MINUTE_IN_SECONDS);
+ return new \WP_Error('wrong_token', __('Incorrect or expired code. Please try again.'), ['status' => 401]);
+ }
+
+ delete_transient($attemptKey);
+ delete_transient($key);
+ set_transient(acore_2fa_unlock_key($user->ID), time(), 30 * MINUTE_IN_SECONDS);
+
+ return ['success' => true];
+ }
+ ) );
+
+ // User: lightweight 2FA status for real-time removal detection on the Security page
+ register_rest_route( ACORE_SLUG . '/v1', '2fa-status', array(
+ 'methods' => 'GET',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function() {
+ $user = wp_get_current_user();
+ $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true);
+ $log = is_array($log) ? $log : [];
+ return [
+ 'website_enabled' => acore_website_2fa_enabled($user->ID),
+ 'ingame_enabled' => acore_ingame_2fa_enabled($user->ID),
+ 'removal_count' => count($log),
+ ];
+ }
+ ) );
+
+ // User: paginated connection history (for "see more" without a page reload)
+ register_rest_route( ACORE_SLUG . '/v1', 'connections', array(
+ 'methods' => 'GET',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function( \WP_REST_Request $request ) {
+ $user = wp_get_current_user();
+ $perPage = 50;
+ $page = max(1, (int) $request->get_param('page'));
+
+ $all = \ACore\Hooks\User\acore_get_login_history($user->ID, 500);
+ $all = is_array($all) ? $all : [];
+ $total = count($all);
+ $offset = ($page - 1) * $perPage;
+ $slice = array_slice($all, $offset, $perPage);
+ $myIp = \ACore\Hooks\User\acore_resolve_client_ip();
+
+ $rows = array_map(function ($r) use ($myIp) {
+ $ip = $r['ip_address'] ?? ($r['ip'] ?? '');
+ return [
+ 'ip' => $ip,
+ 'country' => ($r['country'] ?? '') !== '' ? $r['country'] : 'Unknown',
+ 'date' => ($r['login_at'] ?? '') !== '' ? \ACore\Hooks\User\acore_format_connection_date($r['login_at']) : '',
+ 'where' => (($r['source'] ?? 'website') === 'ingame') ? 'In-game' : 'Website',
+ 'current' => ($ip !== '' && $ip === $myIp),
+ ];
+ }, $slice);
+
+ return [
+ 'rows' => array_values($rows),
+ 'page' => $page,
+ 'total' => $total,
+ 'from' => $total ? $offset + 1 : 0,
+ 'to' => $offset + count($slice),
+ 'has_more' => ($offset + count($slice)) < $total,
+ ];
}
) );
});
diff --git a/src/acore-wp-plugin/src/Components/Tools/ToolsApi.php b/src/acore-wp-plugin/src/Components/Tools/ToolsApi.php
index 16f31175e..3d9347edf 100644
--- a/src/acore-wp-plugin/src/Components/Tools/ToolsApi.php
+++ b/src/acore-wp-plugin/src/Components/Tools/ToolsApi.php
@@ -7,27 +7,43 @@
class ToolsApi {
public static function ItemRestoreList($request) {
- return ACoreServices::I()->getRestorableItemsByCharacter($request['cguid']);
+ try {
+ return ACoreServices::I()->getRestorableItemsByCharacter($request['cguid']);
+ } catch (\InvalidArgumentException $e) {
+ return new \WP_Error('invalid_character', 'Character not found.', ['status' => 403]);
+ }
}
public static function ItemRestore($data) {
- $item = $data['item'];
- $cname = $data['cname'];
- return ACoreServices::I()->getServerSoap()->executeCommand("item restore $item $cname");
+ $cname = isset($data['cname']) && is_string($data['cname']) ? trim($data['cname']) : '';
+ $item = filter_var($data['item'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
+ if ($item === false) {
+ return new \WP_Error('invalid_item', 'Invalid item id.', ['status' => 400]);
+ }
+ if ($cname === '') {
+ return new \WP_Error('invalid_character', 'Invalid character.', ['status' => 400]);
+ }
+ $ownedCharacterName = ACoreServices::I()->getOwnedRestorableItemCharacterName($item, $cname);
+ if ($ownedCharacterName === null) {
+ return new \WP_Error('forbidden', 'Restorable item not found for that character.', ['status' => 403]);
+ }
+ return ACoreServices::I()->getServerSoap()->executeCommand("item restore $item $ownedCharacterName");
}
}
add_action( 'rest_api_init', function () {
register_rest_route( ACORE_SLUG . '/v1', 'item-restore/list/(?P
\d+)', array(
- 'methods' => 'GET',
- 'callback' => function( $request ) {
+ 'methods' => 'GET',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function( $request ) {
return ToolsApi::ItemRestoreList($request);
}
));
register_rest_route( ACORE_SLUG . '/v1', 'item-restore', array(
- 'methods' => 'POST',
- 'callback' => function( $request ) {
+ 'methods' => 'POST',
+ 'permission_callback' => function() { return is_user_logged_in(); },
+ 'callback' => function( $request ) {
// if free item-restoration is disabled, return
if (Opts::I()->acore_item_restoration != '1') {
diff --git a/src/acore-wp-plugin/src/Components/UnstuckMenu/UnstuckView.php b/src/acore-wp-plugin/src/Components/UnstuckMenu/UnstuckView.php
index d3db91d66..fd2c93634 100644
--- a/src/acore-wp-plugin/src/Components/UnstuckMenu/UnstuckView.php
+++ b/src/acore-wp-plugin/src/Components/UnstuckMenu/UnstuckView.php
@@ -2,6 +2,8 @@
namespace ACore\Components\UnstuckMenu;
+use ACore\Utils\AcoreCharColors;
+
class UnstuckView
{
@@ -17,10 +19,14 @@ public function getUnstuckmenuRender($chars)
ob_start();
wp_enqueue_style('bootstrap-css', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', array(), '5.1.3');
- wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.1');
+ wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.5');
wp_enqueue_script('bootstrap-js', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js', array(), '5.1.3');
wp_enqueue_script('jquery');
wp_enqueue_script('acore-unstuck-js', ACORE_URL_PLG . 'web/assets/unstuck/unstuck.js', array('jquery'), null, true);
+ wp_localize_script('acore-unstuck-js', 'unstuckData', array(
+ 'restUrl' => rest_url(ACORE_SLUG . '/v1/unstuck'),
+ 'nonce' => wp_create_nonce('wp_rest'),
+ ));
?>
@@ -31,7 +37,7 @@ public function getUnstuckmenuRender($chars)
Unstuck
Unstuck your characters and teleport them to the hearthstone location
-
+
$currentTime);
@@ -39,28 +45,26 @@ public function getUnstuckmenuRender($chars)
$tooltipText = $isDisabled ? 'Unstuck is on cooldown' : ''; // Tooltip text if disabled
$remainingCDTime = $isDisabled ? $char["time"] - $currentTime : 0;
$endTime = $isDisabled ? $char["time"] : 0;
+ $clsStyle = AcoreCharColors::rowStyle(intval($char["class"]), intval($char["race"]));
?>
-
-
-
-
-
= __('AzerothCore Settings', Opts::I()->page_alias) ?>
-
-
+
+
page_alias); ?>
+
+
+
+
diff --git a/src/acore-wp-plugin/src/Components/UserPanel/Pages/RafProgressPage.php b/src/acore-wp-plugin/src/Components/UserPanel/Pages/RafProgressPage.php
index 1fd3b765c..bb2f8c923 100644
--- a/src/acore-wp-plugin/src/Components/UserPanel/Pages/RafProgressPage.php
+++ b/src/acore-wp-plugin/src/Components/UserPanel/Pages/RafProgressPage.php
@@ -7,18 +7,22 @@
$acServices = ACoreServices::I();
$userId = $acServices->getAcoreAccountId();
?>
-
- " . __('Recruit a Friend', Opts::I()->page_alias) . "";
- ?>
-
Recruit your friends, help them to level up and get very awesome unique prizes.
+
+
page_alias); ?>
+
page_alias); ?>
+
eluna_raf_config['end_raf_on_same_ip'] === '1') { ?>
-
Recruiting from the same IP address will cause the RAF to be automatically removed and no new RAF can be applied again.
+
-
Whilst you can recruit a friend who shares an IP address, you will only get the teleport & XP bonuses, no rewards will be given (these are provided only to those who recruit people not on their own IP address).
+
-
-
+
+
+
= (new \DateTime())) { ?>
You still have until format('D, d M Y H:i'); ?> [server time] to be recruited by a friend, enter his username here: