From 13dd671ac205c63b93a062160ec61cd67a370d3c Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:12:48 +0200 Subject: [PATCH 1/7] Add guided migration wizard, migration key, menus/i18n/permalink export, and hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tools → EmDash Migration wizard: preflight health checks (permalinks, REST loopback, Authorization header, app passwords, reachability) with actionable fixes, one-click migration key, deploy-to-Cloudflare branch, honest what-gets-migrated overview - Migration key: programmatic revocable Application Password packaged as em1. so users never type URLs or credentials - New GET /menus endpoint matching EmDash importMenusFromPlugin() format - Per-post permalink for redirect maps; WPML/Polylang locale + translation_group; i18n info in probe/analyze - Security: require export capability (was edit_posts), remove file_path from media responses, unserialize with allowed_classes=false, 403 via rest_authorization_required_code() - Release workflow: installable zip on tags and PR artifacts --- .github/workflows/release.yml | 42 ++++ plugins/emdash-exporter/assets/wizard.css | 189 ++++++++++++++++ plugins/emdash-exporter/assets/wizard.js | 96 ++++++++ plugins/emdash-exporter/emdash-exporter.php | 53 ++--- .../includes/class-admin-page.php | 212 ++++++++++++++++++ .../includes/class-content-exporter.php | 35 +-- .../includes/class-health-check.php | 196 ++++++++++++++++ .../includes/class-i18n-exporter.php | 142 ++++++++++++ .../includes/class-media-exporter.php | 19 +- .../includes/class-menu-exporter.php | 77 +++++++ .../includes/class-migration-key.php | 115 ++++++++++ .../includes/class-rest-controller.php | 62 ++++- .../emdash-exporter/includes/views/wizard.php | 184 +++++++++++++++ plugins/emdash-exporter/readme.txt | 39 +++- 14 files changed, 1371 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 plugins/emdash-exporter/assets/wizard.css create mode 100644 plugins/emdash-exporter/assets/wizard.js create mode 100644 plugins/emdash-exporter/includes/class-admin-page.php create mode 100644 plugins/emdash-exporter/includes/class-health-check.php create mode 100644 plugins/emdash-exporter/includes/class-i18n-exporter.php create mode 100644 plugins/emdash-exporter/includes/class-menu-exporter.php create mode 100644 plugins/emdash-exporter/includes/class-migration-key.php create mode 100644 plugins/emdash-exporter/includes/views/wizard.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..91a3a1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release plugin zip + +# Builds an installable emdash-exporter.zip (plugin folder at the archive +# root, so WordPress' "Upload Plugin" accepts it) on every tag push, and as +# a downloadable artifact on pull requests for testing. + +on: + push: + tags: + - "v*" + pull_request: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: PHP syntax check + run: | + find plugins/emdash-exporter -name '*.php' -print0 | xargs -0 -n1 php -l + + - name: Build zip + run: | + cd plugins + zip -r ../emdash-exporter.zip emdash-exporter + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: emdash-exporter + path: emdash-exporter.zip + + - name: Create release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: emdash-exporter.zip + generate_release_notes: true diff --git a/plugins/emdash-exporter/assets/wizard.css b/plugins/emdash-exporter/assets/wizard.css new file mode 100644 index 0000000..1c90ee5 --- /dev/null +++ b/plugins/emdash-exporter/assets/wizard.css @@ -0,0 +1,189 @@ +.emdash-wizard { + max-width: 860px; +} + +.emdash-title { + margin-bottom: 20px; +} + +.emdash-title-sub { + color: #2271b1; + font-weight: 600; +} + +.emdash-step { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 6px; + margin-bottom: 16px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); +} + +.emdash-step-header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f1; +} + +.emdash-step-header h2 { + margin: 0 0 4px; + font-size: 16px; +} + +.emdash-step-header p { + margin: 0; + color: #646970; +} + +.emdash-step-badge { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: #f0f0f1; + color: #3c434a; + font-weight: 700; +} + +.emdash-step.is-active .emdash-step-badge { + background: #2271b1; + color: #fff; +} + +.emdash-step.is-done .emdash-step-badge { + background: #00a32a; + color: #fff; +} + +.emdash-step-status { + margin-inline-start: auto; + align-self: center; + color: #00a32a; + font-weight: 600; + white-space: nowrap; +} + +.emdash-step-body { + padding: 16px 20px 20px; +} + +.emdash-checks { + margin: 0 0 16px; +} + +.emdash-check { + display: flex; + gap: 10px; + padding: 10px 12px; + border-radius: 4px; + margin-bottom: 8px; + background: #f6f7f7; +} + +.emdash-check p { + margin: 2px 0 0; +} + +.emdash-check-icon { + flex-shrink: 0; + font-weight: 700; + width: 18px; + text-align: center; +} + +.emdash-check--pass .emdash-check-icon { color: #00a32a; } +.emdash-check--warn { background: #fcf9e8; } +.emdash-check--warn .emdash-check-icon { color: #dba617; } +.emdash-check--fail { background: #fcf0f1; } +.emdash-check--fail .emdash-check-icon { color: #d63638; } + +.emdash-check-fix { + color: #646970; +} + +.emdash-check-fix code { + user-select: all; +} + +.emdash-key-row { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.emdash-key-input { + flex: 1; + font-family: Consolas, Monaco, monospace; + font-size: 13px; + padding: 6px 10px; +} + +#emdash-key-error { + margin-top: 12px; +} + +.emdash-columns { + display: flex; + gap: 24px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.emdash-column { + flex: 1; + min-width: 280px; +} + +.emdash-column h3 { + margin-top: 4px; +} + +.emdash-deploy-btn { + display: inline-flex !important; + align-items: center; + gap: 8px; + background: #f6821f !important; + border-color: #f6821f !important; +} + +.emdash-deploy-btn:hover, +.emdash-deploy-btn:focus { + background: #e07408 !important; + border-color: #e07408 !important; +} + +.emdash-cf-icon { + display: inline-block; + width: 18px; + height: 12px; + background: #fff; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 16'%3E%3Cpath d='M16.5 13.7l.4-1.4c.2-.8.1-1.5-.3-2-.4-.5-1-.8-1.8-.8l-9.7-.1c-.1 0-.1 0-.2-.1v-.2c0-.1.1-.1.2-.1l9.8-.1c1.2-.1 2.4-1 2.9-2.2l.6-1.5v-.3C17.7 2 15.2 0 12.2 0 9.4 0 7 1.8 6.2 4.3c-.5-.4-1.2-.6-2-.5C2.9 4 1.9 5 1.7 6.3c0 .3 0 .7.1 1C-.3 7.4 0 9.2 0 10.9c0 .1.1.2.2.2h16.1c.1 0 .2 0 .2-.1v2.7z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 16'%3E%3Cpath d='M16.5 13.7l.4-1.4c.2-.8.1-1.5-.3-2-.4-.5-1-.8-1.8-.8l-9.7-.1c-.1 0-.1 0-.2-.1v-.2c0-.1.1-.1.2-.1l9.8-.1c1.2-.1 2.4-1 2.9-2.2l.6-1.5v-.3C17.7 2 15.2 0 12.2 0 9.4 0 7 1.8 6.2 4.3c-.5-.4-1.2-.6-2-.5C2.9 4 1.9 5 1.7 6.3c0 .3 0 .7.1 1C-.3 7.4 0 9.2 0 10.9c0 .1.1.2.2.2h16.1c.1 0 .2 0 .2-.1v2.7z'/%3E%3C/svg%3E") center / contain no-repeat; +} + +.emdash-overview { + margin: 0; +} + +.emdash-overview li { + padding: 4px 0; + border-bottom: 1px solid #f0f0f1; +} + +.emdash-count { + color: #646970; + font-size: 12px; + background: #f0f0f1; + border-radius: 10px; + padding: 1px 8px; + margin-inline-start: 6px; +} + +.emdash-yes { color: #00a32a; } +.emdash-no { color: #d63638; } +.emdash-overview--excluded li { color: #646970; } diff --git a/plugins/emdash-exporter/assets/wizard.js b/plugins/emdash-exporter/assets/wizard.js new file mode 100644 index 0000000..7196a45 --- /dev/null +++ b/plugins/emdash-exporter/assets/wizard.js @@ -0,0 +1,96 @@ +/** + * EmDash migration wizard: key generation, copy, revoke. + * Vanilla JS, no dependencies. + */ +(function () { + "use strict"; + + var cfg = window.emdashWizard || {}; + var generateBtn = document.getElementById("emdash-generate-key"); + var revokeBtn = document.getElementById("emdash-revoke-key"); + var result = document.getElementById("emdash-key-result"); + var keyInput = document.getElementById("emdash-key-value"); + var copyBtn = document.getElementById("emdash-copy-key"); + var errorBox = document.getElementById("emdash-key-error"); + + function post(action) { + var body = new URLSearchParams(); + body.set("action", action); + body.set("nonce", cfg.nonce); + return fetch(cfg.ajaxUrl, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }).then(function (response) { + return response.json(); + }); + } + + function showError(message) { + if (!errorBox) return; + errorBox.querySelector("p").textContent = message || cfg.i18n.error; + errorBox.hidden = false; + } + + if (generateBtn) { + generateBtn.addEventListener("click", function () { + generateBtn.disabled = true; + var originalText = generateBtn.textContent; + generateBtn.textContent = cfg.i18n.generating; + errorBox.hidden = true; + + post("emdash_generate_key") + .then(function (data) { + if (data && data.success && data.data && data.data.key) { + keyInput.value = data.data.key; + result.hidden = false; + keyInput.focus(); + keyInput.select(); + } else { + showError(data && data.data && data.data.message); + } + }) + .catch(function () { + showError(); + }) + .finally(function () { + generateBtn.disabled = false; + generateBtn.textContent = originalText; + }); + }); + } + + if (copyBtn) { + copyBtn.addEventListener("click", function () { + keyInput.select(); + var done = function () { + copyBtn.textContent = cfg.i18n.copied; + setTimeout(function () { + copyBtn.textContent = cfg.i18n.copy; + }, 2000); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(keyInput.value).then(done); + } else { + document.execCommand("copy"); + done(); + } + }); + } + + if (revokeBtn) { + revokeBtn.addEventListener("click", function () { + if (!window.confirm(cfg.i18n.revokeConfirm)) return; + revokeBtn.disabled = true; + post("emdash_revoke_key") + .then(function () { + window.location.reload(); + }) + .catch(function () { + revokeBtn.disabled = false; + showError(); + }); + }); + } +})(); diff --git a/plugins/emdash-exporter/emdash-exporter.php b/plugins/emdash-exporter/emdash-exporter.php index c76f30b..a92887a 100644 --- a/plugins/emdash-exporter/emdash-exporter.php +++ b/plugins/emdash-exporter/emdash-exporter.php @@ -2,8 +2,8 @@ /** * Plugin Name: EmDash Exporter * Plugin URI: https://github.com/emdash-cms/wp-emdash - * Description: Export your WordPress content to EmDash CMS with one click - * Version: 1.0.0 + * Description: Migrate your WordPress content to EmDash CMS with a guided wizard and one-click migration key + * Version: 1.1.0 * Requires at least: 5.6 * Requires PHP: 7.4 * Author: Matt Kane @@ -13,57 +13,34 @@ defined('ABSPATH') || exit; -define('EMDASH_EXPORTER_VERSION', '1.0.0'); +define('EMDASH_EXPORTER_VERSION', '1.1.0'); define('EMDASH_EXPORTER_PATH', plugin_dir_path(__FILE__)); -// Load REST API endpoints require_once EMDASH_EXPORTER_PATH . 'includes/class-rest-controller.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-content-exporter.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-media-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-menu-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-i18n-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-health-check.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-migration-key.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-admin-page.php'; /** - * Initialize the plugin + * Register REST API routes */ function emdash_exporter_init() { - // Register REST API routes $controller = new EmDash_Exporter_REST_Controller(); $controller->register_routes(); } add_action('rest_api_init', 'emdash_exporter_init'); -/** - * Add admin notice with connection info - */ -function emdash_exporter_admin_notice() { - $screen = get_current_screen(); - if ($screen->id !== 'plugins') { - return; - } - - $site_url = get_site_url(); - $api_url = rest_url('emdash/v1/'); - - ?> -
-

- EmDash Exporter: - Your site is ready for export. Connect from EmDash using: - -

-

- API endpoint: -

-
- register(); /** - * Add settings link on plugins page + * Send the user into the wizard right after activation. */ -function emdash_exporter_settings_link($links) { - $api_url = rest_url('emdash/v1/probe'); - $links[] = 'Test API'; - return $links; +function emdash_exporter_activate() { + set_transient('emdash_exporter_activated', 1, 60); } -add_filter('plugin_action_links_' . plugin_basename(__FILE__), 'emdash_exporter_settings_link'); +register_activation_hook(__FILE__, 'emdash_exporter_activate'); diff --git a/plugins/emdash-exporter/includes/class-admin-page.php b/plugins/emdash-exporter/includes/class-admin-page.php new file mode 100644 index 0000000..6e45be6 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-admin-page.php @@ -0,0 +1,212 @@ + admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('emdash_wizard'), + 'i18n' => [ + 'copied' => __('Copied!', 'emdash-exporter'), + 'copy' => __('Copy key', 'emdash-exporter'), + 'generating' => __('Generating…', 'emdash-exporter'), + 'error' => __('Something went wrong. Please try again.', 'emdash-exporter'), + 'revokeConfirm' => __('Revoke the migration key? EmDash will no longer be able to connect until you generate a new one.', 'emdash-exporter'), + ], + ]); + } + + /** + * Plugins-page action link to the wizard (replaces the old raw-JSON "Test API" link). + */ + public function action_links($links) { + array_unshift($links, sprintf( + '%s', + esc_url(admin_url('tools.php?page=' . self::SLUG)), + esc_html__('Start migration', 'emdash-exporter') + )); + return $links; + } + + /** + * One-time redirect into the wizard right after activation. + */ + public function maybe_redirect_after_activation() { + if (!get_transient('emdash_exporter_activated')) { + return; + } + delete_transient('emdash_exporter_activated'); + if (wp_doing_ajax() || !current_user_can('export')) { + return; + } + wp_safe_redirect(admin_url('tools.php?page=' . self::SLUG)); + exit; + } + + /** + * Small pointer on the plugins screen only (replaces the old always-on notice). + */ + public function activation_pointer() { + $screen = get_current_screen(); + if (!$screen || $screen->id !== 'plugins' || !current_user_can('export')) { + return; + } + printf( + '

%s %s %s

', + esc_html__('EmDash Exporter:', 'emdash-exporter'), + esc_html__('Ready to move this site to EmDash?', 'emdash-exporter'), + esc_url(admin_url('tools.php?page=' . self::SLUG)), + esc_html__('Start the migration wizard', 'emdash-exporter') + ); + } + + public function ajax_generate_key() { + check_ajax_referer('emdash_wizard', 'nonce'); + if (!current_user_can('export')) { + wp_send_json_error(['message' => __('You need export permissions to generate a migration key.', 'emdash-exporter')], 403); + } + + $result = EmDash_Migration_Key::generate(); + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + wp_send_json_success($result); + } + + public function ajax_revoke_key() { + check_ajax_referer('emdash_wizard', 'nonce'); + if (!current_user_can('export')) { + wp_send_json_error(['message' => __('You need export permissions.', 'emdash-exporter')], 403); + } + EmDash_Migration_Key::revoke(); + wp_send_json_success(); + } + + public function render() { + $health = new EmDash_Health_Check(); + $checks = $health->run(); + $checks_ok = EmDash_Health_Check::all_ok($checks); + $key_exists = EmDash_Migration_Key::exists(); + $overview = $this->content_overview(); + + include EMDASH_EXPORTER_PATH . 'includes/views/wizard.php'; + } + + /** + * Honest "what gets migrated" overview data. + */ + private function content_overview() { + $post_types = get_post_types(['public' => true], 'objects'); + $types = []; + foreach ($post_types as $type) { + if ($type->name === 'attachment') { + continue; + } + $counts = wp_count_posts($type->name); + $total = 0; + foreach (['publish', 'draft', 'pending', 'private', 'future'] as $status) { + $total += isset($counts->$status) ? (int) $counts->$status : 0; + } + if ($total > 0) { + $types[] = ['label' => $type->label, 'count' => $total]; + } + } + + $taxonomies = []; + foreach (get_taxonomies(['public' => true], 'objects') as $taxonomy) { + $count = wp_count_terms(['taxonomy' => $taxonomy->name, 'hide_empty' => false]); + if (!is_wp_error($count) && (int) $count > 0) { + $taxonomies[] = ['label' => $taxonomy->label, 'count' => (int) $count]; + } + } + + $non_public = array_diff( + get_post_types(['public' => false]), + // WP-internal types that nobody expects to migrate + ['revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_font_family', 'wp_font_face', 'attachment'] + ); + + $i18n = EmDash_I18n_Exporter::site_info(); + + return [ + 'types' => $types, + 'taxonomies' => $taxonomies, + 'media_count' => (int) wp_count_posts('attachment')->inherit, + 'menu_count' => count(wp_get_nav_menus()), + 'acf' => class_exists('ACF'), + 'yoast' => defined('WPSEO_VERSION'), + 'rankmath' => class_exists('RankMath'), + 'i18n' => $i18n, + 'non_public_types' => array_values($non_public), + 'page_builder' => $this->detect_page_builder(), + ]; + } + + /** + * Detect page builders whose layouts cannot be converted 1:1. + */ + private function detect_page_builder() { + $builders = []; + if (defined('ELEMENTOR_VERSION')) { + $builders[] = 'Elementor'; + } + if (defined('ET_BUILDER_VERSION') || function_exists('et_setup_theme')) { + $builders[] = 'Divi'; + } + if (defined('WPB_VC_VERSION')) { + $builders[] = 'WPBakery'; + } + if (class_exists('FLBuilder')) { + $builders[] = 'Beaver Builder'; + } + if (defined('BRICKS_VERSION')) { + $builders[] = 'Bricks'; + } + return $builders; + } +} diff --git a/plugins/emdash-exporter/includes/class-content-exporter.php b/plugins/emdash-exporter/includes/class-content-exporter.php index c279d8a..e54613a 100644 --- a/plugins/emdash-exporter/includes/class-content-exporter.php +++ b/plugins/emdash-exporter/includes/class-content-exporter.php @@ -102,6 +102,12 @@ public function analyze() { $result['acf'] = $this->analyze_acf_fields(); } + // Multilingual plugin info (WPML/Polylang) if available + $i18n = EmDash_I18n_Exporter::site_info(); + if ($i18n) { + $result['i18n'] = $i18n; + } + return $result; } @@ -156,8 +162,17 @@ private function format_post($post, $include_meta = true) { 'comment_status' => $post->comment_status, 'ping_status' => $post->ping_status, 'guid' => $post->guid, + // The real public URL, so the importer can build a redirect map + // that preserves SEO when the URL structure changes. + 'permalink' => get_permalink($post), ]; + // Locale + translation group from WPML/Polylang, when active + $i18n = EmDash_I18n_Exporter::post_info($post->ID); + if (!empty($i18n)) { + $data = array_merge($data, $i18n); + } + // Taxonomies $taxonomies = get_object_taxonomies($post->post_type); $data['taxonomies'] = []; @@ -274,9 +289,10 @@ private function get_post_meta_clean($post_id) { // Single values are unwrapped $clean_meta[$key] = count($values) === 1 ? $values[0] : $values; - // Try to unserialize PHP serialized data - if (is_string($clean_meta[$key]) && $this->is_serialized($clean_meta[$key])) { - $unserialized = @unserialize($clean_meta[$key]); + // Decode PHP-serialized data. allowed_classes=false prevents + // object injection from untrusted database values. + if (is_string($clean_meta[$key]) && is_serialized($clean_meta[$key])) { + $unserialized = @unserialize($clean_meta[$key], ['allowed_classes' => false]); if ($unserialized !== false) { $clean_meta[$key] = $unserialized; } @@ -286,17 +302,6 @@ private function get_post_meta_clean($post_id) { return $clean_meta; } - /** - * Check if a string is PHP serialized - */ - private function is_serialized($data) { - if (!is_string($data)) return false; - $data = trim($data); - if ($data === 'N;') return true; - if (preg_match('/^([aOsbi]):/', $data)) return true; - return false; - } - /** * Analyze custom fields used by a post type */ @@ -361,7 +366,7 @@ private function infer_field_type($key, $sample) { if (!$sample) return 'string'; // Check sample value - if ($this->is_serialized($sample)) { + if (is_serialized($sample)) { return 'json'; } if (is_numeric($sample)) { diff --git a/plugins/emdash-exporter/includes/class-health-check.php b/plugins/emdash-exporter/includes/class-health-check.php new file mode 100644 index 0000000..326af19 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-health-check.php @@ -0,0 +1,196 @@ +check_permalinks(), + $this->check_application_passwords(), + $this->check_rest_loopback(), + $this->check_authorization_header(), + $this->check_reachability(), + ]; + } + + /** True when every check passes (warnings are acceptable). */ + public static function all_ok(array $checks) { + foreach ($checks as $check) { + if ($check['status'] === 'fail') { + return false; + } + } + return true; + } + + private function check_permalinks() { + $structure = get_option('permalink_structure'); + if ($structure) { + return [ + 'id' => 'permalinks', + 'label' => __('Pretty permalinks', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('Pretty permalinks are enabled, the REST API is available at /wp-json/.', 'emdash-exporter'), + ]; + } + return [ + 'id' => 'permalinks', + 'label' => __('Pretty permalinks', 'emdash-exporter'), + 'status' => 'warn', + 'message' => __('Plain permalinks are active. The REST API is only reachable via ?rest_route=, which some importers do not try.', 'emdash-exporter'), + 'fix' => sprintf( + /* translators: %s: URL of the permalinks settings screen */ + __('Go to Settings → Permalinks and choose any structure other than "Plain".', 'emdash-exporter'), + esc_url(admin_url('options-permalink.php')) + ), + ]; + } + + private function check_application_passwords() { + if (wp_is_application_passwords_available()) { + return [ + 'id' => 'app_passwords', + 'label' => __('Application Passwords', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('Application Passwords are available for API authentication.', 'emdash-exporter'), + ]; + } + return [ + 'id' => 'app_passwords', + 'label' => __('Application Passwords', 'emdash-exporter'), + 'status' => 'fail', + 'message' => __('Application Passwords are disabled on this site. EmDash cannot authenticate without them.', 'emdash-exporter'), + 'fix' => is_ssl() + ? __('A security plugin or a filter (wp_is_application_passwords_available) is disabling them. Re-enable Application Passwords in your security plugin settings.', 'emdash-exporter') + : __('WordPress disables Application Passwords on non-HTTPS sites. Enable HTTPS for this site, then reload this page.', 'emdash-exporter'), + ]; + } + + private function check_rest_loopback() { + $response = wp_remote_get(rest_url('emdash/v1/probe'), [ + 'timeout' => 10, + 'sslverify' => false, // ponytail: loopback to self; local certs are often self-signed + ]); + + if (is_wp_error($response)) { + return [ + 'id' => 'rest', + 'label' => __('REST API reachable', 'emdash-exporter'), + 'status' => 'fail', + 'message' => sprintf( + /* translators: %s: error message */ + __('This site cannot reach its own REST API: %s', 'emdash-exporter'), + esc_html($response->get_error_message()) + ), + 'fix' => __('Loopback requests are blocked. Check with your host, or look for security plugins blocking the REST API.', 'emdash-exporter'), + ]; + } + + $code = wp_remote_retrieve_response_code($response); + if ($code !== 200) { + return [ + 'id' => 'rest', + 'label' => __('REST API reachable', 'emdash-exporter'), + 'status' => 'fail', + 'message' => sprintf( + /* translators: %d: HTTP status code */ + __('The EmDash Exporter REST endpoint returned HTTP %d instead of 200.', 'emdash-exporter'), + (int) $code + ), + 'fix' => __('A security plugin or firewall rule is likely blocking /wp-json/. Allow the emdash/v1 namespace.', 'emdash-exporter'), + ]; + } + + return [ + 'id' => 'rest', + 'label' => __('REST API reachable', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('The EmDash Exporter REST API responds correctly.', 'emdash-exporter'), + ]; + } + + /** + * The classic Application Password failure: Apache/CGI setups strip the + * Authorization header before PHP sees it. We loop back to our own + * header-check endpoint with a dummy header and see if it arrives. + */ + private function check_authorization_header() { + $response = wp_remote_get(rest_url('emdash/v1/header-check'), [ + 'timeout' => 10, + 'sslverify' => false, + 'headers' => ['Authorization' => 'Basic ' . base64_encode('emdash:header-check')], + ]); + + $label = __('Authorization header', 'emdash-exporter'); + + if (is_wp_error($response)) { + return [ + 'id' => 'auth_header', + 'label' => $label, + 'status' => 'warn', + 'message' => __('Could not verify (loopback request failed). Authentication may still work from outside.', 'emdash-exporter'), + ]; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + if (is_array($body) && !empty($body['authorization_header_received'])) { + return [ + 'id' => 'auth_header', + 'label' => $label, + 'status' => 'pass', + 'message' => __('The Authorization header reaches WordPress. Application Password logins will work.', 'emdash-exporter'), + ]; + } + + return [ + 'id' => 'auth_header', + 'label' => $label, + 'status' => 'fail', + 'message' => __('Your server strips the Authorization header before it reaches WordPress, so Application Password logins fail.', 'emdash-exporter'), + 'fix' => __('Add this line to your .htaccess, directly after "RewriteEngine On":
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]', 'emdash-exporter'), + ]; + } + + private function check_reachability() { + $host = wp_parse_url(get_site_url(), PHP_URL_HOST); + $host = is_string($host) ? strtolower($host) : ''; + + $is_local = $host === 'localhost' + || substr($host, -6) === '.local' + || substr($host, -5) === '.test' + || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)); + + if ($is_local) { + return [ + 'id' => 'reachability', + 'label' => __('Publicly reachable', 'emdash-exporter'), + 'status' => 'warn', + 'message' => sprintf( + /* translators: %s: host name */ + __('This site (%s) is only reachable on your local machine. A cloud-hosted EmDash site cannot connect to it.', 'emdash-exporter'), + esc_html($host) + ), + 'fix' => __('Run EmDash locally on the same machine (npx emdash dev), or use a tunnel (e.g. cloudflared) to expose this site temporarily.', 'emdash-exporter'), + ]; + } + + return [ + 'id' => 'reachability', + 'label' => __('Publicly reachable', 'emdash-exporter'), + 'status' => 'pass', + 'message' => __('The site URL looks publicly reachable.', 'emdash-exporter'), + ]; + } +} diff --git a/plugins/emdash-exporter/includes/class-i18n-exporter.php b/plugins/emdash-exporter/includes/class-i18n-exporter.php new file mode 100644 index 0000000..e361716 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-i18n-exporter.php @@ -0,0 +1,142 @@ + 'wpml', + 'default_locale' => self::to_bcp47($default_locale ?: get_locale()), + 'locales' => $locales, + ]; + } + + // Polylang + $locales = []; + $slugs = pll_languages_list(['fields' => 'slug']); + foreach ((array) $slugs as $slug) { + $locales[] = self::polylang_locale($slug); + } + $default_slug = function_exists('pll_default_language') ? pll_default_language('slug') : null; + + return [ + 'plugin' => 'polylang', + 'default_locale' => $default_slug ? self::polylang_locale($default_slug) : self::to_bcp47(get_locale()), + 'locales' => $locales, + ]; + } + + /** + * Per-post i18n fields, or [] when not applicable. + * + * @param int $post_id + * @return array May contain 'locale' and 'translation_group'. + */ + public static function post_info($post_id) { + $plugin = self::active_plugin(); + if ($plugin === null) { + return []; + } + + if ($plugin === 'wpml') { + $data = []; + $details = apply_filters('wpml_post_language_details', null, $post_id); + if (is_array($details) && !empty($details['locale'])) { + $data['locale'] = self::to_bcp47($details['locale']); + } + $post_type = get_post_type($post_id); + $trid = apply_filters('wpml_element_trid', null, $post_id, 'post_' . $post_type); + if ($trid) { + $data['translation_group'] = 'wpml-' . $trid; + } + return $data; + } + + // Polylang + $data = []; + if (function_exists('pll_get_post_language')) { + $slug = pll_get_post_language($post_id, 'slug'); + if ($slug) { + $data['locale'] = self::polylang_locale($slug); + } + } + if (function_exists('pll_get_post_translations')) { + $translations = pll_get_post_translations($post_id); + if (is_array($translations) && count($translations) > 0) { + // ponytail: smallest post ID in the group is a stable group key + // without needing Polylang's internal term IDs. + $data['translation_group'] = 'pll-' . min(array_map('intval', $translations)); + } + } + return $data; + } + + /** + * Full BCP 47 locale for a Polylang language slug (e.g. 'de' -> 'de-DE'). + */ + private static function polylang_locale($slug) { + if (function_exists('PLL')) { + $language = PLL()->model->get_language($slug); + if ($language && !empty($language->locale)) { + return self::to_bcp47($language->locale); + } + } + return self::to_bcp47($slug); + } + + /** + * WordPress locale ('de_DE') to BCP 47 ('de-DE'). + */ + private static function to_bcp47($locale) { + return str_replace('_', '-', (string) $locale); + } +} diff --git a/plugins/emdash-exporter/includes/class-media-exporter.php b/plugins/emdash-exporter/includes/class-media-exporter.php index 736d228..ba7175c 100644 --- a/plugins/emdash-exporter/includes/class-media-exporter.php +++ b/plugins/emdash-exporter/includes/class-media-exporter.php @@ -67,7 +67,6 @@ private function format_attachment($attachment, $include_data = false) { 'id' => $attachment->ID, 'url' => $url, 'filename' => basename($url), - 'file_path' => $file_path, 'mime_type' => $attachment->post_mime_type, 'title' => $attachment->post_title, 'alt' => get_post_meta($attachment->ID, '_wp_attachment_image_alt', true), @@ -152,9 +151,10 @@ private function get_attachment_custom_meta($attachment_id) { // Single values $custom[$key] = count($values) === 1 ? $values[0] : $values; - // Try to unserialize - if (is_string($custom[$key]) && $this->is_serialized($custom[$key])) { - $unserialized = @unserialize($custom[$key]); + // Decode PHP-serialized data. allowed_classes=false prevents + // object injection from untrusted database values. + if (is_string($custom[$key]) && is_serialized($custom[$key])) { + $unserialized = @unserialize($custom[$key], ['allowed_classes' => false]); if ($unserialized !== false) { $custom[$key] = $unserialized; } @@ -163,15 +163,4 @@ private function get_attachment_custom_meta($attachment_id) { return $custom; } - - /** - * Check if a string is PHP serialized - */ - private function is_serialized($data) { - if (!is_string($data)) return false; - $data = trim($data); - if ($data === 'N;') return true; - if (preg_match('/^([aOsbi]):/', $data)) return true; - return false; - } } diff --git a/plugins/emdash-exporter/includes/class-menu-exporter.php b/plugins/emdash-exporter/includes/class-menu-exporter.php new file mode 100644 index 0000000..1cb55c0 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-menu-exporter.php @@ -0,0 +1,77 @@ +term_id, ['update_post_term_cache' => false]); + if ($items === false) { + $items = []; + } + + $result[] = [ + 'id' => (int) $menu->term_id, + 'name' => $menu->slug, + 'label' => $menu->name, + 'locations' => $this->menu_locations($menu->term_id), + 'items' => array_map([$this, 'format_item'], $items), + ]; + } + + return $result; + } + + /** + * Theme locations this menu is assigned to (e.g. ['primary']). + */ + private function menu_locations($menu_id) { + $locations = []; + foreach ((array) get_nav_menu_locations() as $location => $assigned_id) { + if ((int) $assigned_id === (int) $menu_id) { + $locations[] = $location; + } + } + return $locations; + } + + /** + * Format a single nav menu item (WP_Post decorated by wp_setup_nav_menu_item). + */ + private function format_item($item) { + $type = $item->type; // 'custom' | 'post_type' | 'taxonomy' | 'post_type_archive' + if (!in_array($type, ['custom', 'post_type', 'taxonomy'], true)) { + // ponytail: archives etc. degrade to a custom link; EmDash menus + // have no archive item type. + $type = 'custom'; + } + + return [ + 'id' => (int) $item->ID, + 'parent_id' => $item->menu_item_parent ? (int) $item->menu_item_parent : null, + 'sort_order' => (int) $item->menu_order, + 'type' => $type, + 'object' => $item->object ?: null, + 'object_id' => $item->object_id ? (int) $item->object_id : null, + 'url' => (string) $item->url, + 'title' => (string) $item->title, + 'target' => $item->target ?: null, + 'classes' => is_array($item->classes) ? trim(implode(' ', array_filter($item->classes))) ?: null : null, + ]; + } +} diff --git a/plugins/emdash-exporter/includes/class-migration-key.php b/plugins/emdash-exporter/includes/class-migration-key.php new file mode 100644 index 0000000..626255b --- /dev/null +++ b/plugins/emdash-exporter/includes/class-migration-key.php @@ -0,0 +1,115 @@ +exists()) { + return new WP_Error('not_logged_in', __('You must be logged in.', 'emdash-exporter')); + } + + if (!wp_is_application_passwords_available_for_user($user)) { + return new WP_Error( + 'app_passwords_unavailable', + __('Application Passwords are not available for your user account.', 'emdash-exporter') + ); + } + + // Revoke a previous migration password so regenerating the key + // invalidates the old one instead of piling up credentials. + foreach (WP_Application_Passwords::get_user_application_passwords($user->ID) as $item) { + if ($item['name'] === self::APP_NAME) { + WP_Application_Passwords::delete_application_password($user->ID, $item['uuid']); + } + } + + $created = WP_Application_Passwords::create_new_application_password($user->ID, [ + 'name' => self::APP_NAME, + ]); + + if (is_wp_error($created)) { + return $created; + } + + list($password) = $created; + + $payload = wp_json_encode([ + 'v' => 1, + 'url' => get_site_url(), + 'user' => $user->user_login, + 'pass' => $password, + ]); + + return [ + 'key' => self::PREFIX . self::base64url_encode($payload), + 'user' => $user->user_login, + ]; + } + + /** + * Revoke the migration application password for the current user. + * + * @return bool Whether a password was revoked. + */ + public static function revoke() { + $user = wp_get_current_user(); + if (!$user || !$user->exists()) { + return false; + } + + $revoked = false; + foreach (WP_Application_Passwords::get_user_application_passwords($user->ID) as $item) { + if ($item['name'] === self::APP_NAME) { + WP_Application_Passwords::delete_application_password($user->ID, $item['uuid']); + $revoked = true; + } + } + return $revoked; + } + + /** + * Whether a migration password currently exists for the current user. + */ + public static function exists() { + $user = wp_get_current_user(); + if (!$user || !$user->exists()) { + return false; + } + foreach (WP_Application_Passwords::get_user_application_passwords($user->ID) as $item) { + if ($item['name'] === self::APP_NAME) { + return true; + } + } + return false; + } + + private static function base64url_encode($data) { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/plugins/emdash-exporter/includes/class-rest-controller.php b/plugins/emdash-exporter/includes/class-rest-controller.php index 79f0577..e7c3bc6 100644 --- a/plugins/emdash-exporter/includes/class-rest-controller.php +++ b/plugins/emdash-exporter/includes/class-rest-controller.php @@ -119,30 +119,66 @@ public function register_routes() { 'callback' => [$this, 'get_options'], 'permission_callback' => [$this, 'check_permission'], ]); + + register_rest_route(self::NAMESPACE, '/menus', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_menus'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Public: reflects whether the Authorization header reaches PHP. + // Used by the wizard's loopback health check (some Apache/CGI setups + // strip the header, which silently breaks Application Passwords). + register_rest_route(self::NAMESPACE, '/header-check', [ + 'methods' => 'GET', + 'callback' => [$this, 'header_check'], + 'permission_callback' => '__return_true', + ]); } /** - * Check if user has permission to export + * Check if user has permission to export. + * + * Requires the `export` capability (Administrators and Editors). The + * export exposes all content including drafts, private posts, author + * emails, and site options -- `edit_posts` (Contributors) is not enough. */ public function check_permission() { - // Allow if user is logged in and can export if (current_user_can('export')) { return true; } - // Check for Application Password authentication - // WordPress 5.6+ supports this natively - if (is_user_logged_in() && current_user_can('edit_posts')) { - return true; - } - return new WP_Error( 'rest_forbidden', __('You must be authenticated with export permissions.', 'emdash-exporter'), - ['status' => 401] + ['status' => rest_authorization_required_code()] ); } + /** + * Header check endpoint - reports whether an Authorization header arrived + */ + public function header_check() { + $header = null; + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $header = $_SERVER['HTTP_AUTHORIZATION']; + } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { + $header = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } + + return [ + 'authorization_header_received' => !empty($header), + ]; + } + + /** + * Get all navigation menus + */ + public function get_menus() { + $exporter = new EmDash_Menu_Exporter(); + return $exporter->get_menus(); + } + /** * Probe endpoint - returns site info and capabilities * No authentication required @@ -169,7 +205,9 @@ public function probe() { $has_yoast = defined('WPSEO_VERSION'); $has_rankmath = class_exists('RankMath'); - return [ + $i18n = EmDash_I18n_Exporter::site_info(); + + return array_merge($i18n ? ['i18n' => $i18n] : [], [ 'emdash_exporter' => EMDASH_EXPORTER_VERSION, 'wordpress_version' => get_bloginfo('version'), 'site' => [ @@ -188,15 +226,17 @@ public function probe() { ], 'post_types' => $post_type_info, 'media_count' => (int) wp_count_posts('attachment')->inherit, + 'menu_count' => count(wp_get_nav_menus()), 'endpoints' => [ 'analyze' => rest_url(self::NAMESPACE . '/analyze'), 'content' => rest_url(self::NAMESPACE . '/content'), 'media' => rest_url(self::NAMESPACE . '/media'), 'taxonomies' => rest_url(self::NAMESPACE . '/taxonomies'), 'options' => rest_url(self::NAMESPACE . '/options'), + 'menus' => rest_url(self::NAMESPACE . '/menus'), ], 'auth_instructions' => $this->get_auth_instructions(), - ]; + ]); } /** diff --git a/plugins/emdash-exporter/includes/views/wizard.php b/plugins/emdash-exporter/includes/views/wizard.php new file mode 100644 index 0000000..2f9a6d7 --- /dev/null +++ b/plugins/emdash-exporter/includes/views/wizard.php @@ -0,0 +1,184 @@ + +
+

+ + +

+ + +
+
+ +
+

+

+
+ + + +
+
+
    + +
  • + +
    + +

    + +

    + +
    +
  • + +
+ + + +
+
+ + +
+
+ 2 +
+

+

+
+
+
+
+ + + +

+ + + +
+
+
+ + +
+
+ 3 +
+

+

+
+
+
+
+
+

+
    +
  1. +
  2. +
  3. +
+
+
+

+

+ + + + +

+ +

+

+
    +
  1. +
  2. +
  3. +
+
+
+ +

+
+
+

+
    + +
  • + + +
  • + +
  • + 0) : ?> +
  • + + +
  • + + +
  • + + +
  • + + +
  • + +
+
+
+

+
    + +
  • + +
  • +
  • +
  • +
  • +
+
+
+
+
+
diff --git a/plugins/emdash-exporter/readme.txt b/plugins/emdash-exporter/readme.txt index d1c4a6c..65cb87b 100644 --- a/plugins/emdash-exporter/readme.txt +++ b/plugins/emdash-exporter/readme.txt @@ -4,36 +4,39 @@ Tags: export, migration, cms, emdash, astro Requires at least: 5.6 Tested up to: 6.4 Requires PHP: 7.4 -Stable tag: 1.0.0 +Stable tag: 1.1.0 License: GPL3 License URI: https://opensource.org/license/gpl-3.0 -Export your WordPress content to EmDash CMS via REST API. Full support for posts, pages, custom post types, ACF fields, media, and SEO data. +Migrate your WordPress site to EmDash CMS with a guided wizard. Full support for posts, pages, custom post types, ACF fields, media, menus, translations, and SEO data. == Description == -EmDash Exporter adds REST API endpoints to your WordPress site that allow EmDash CMS to import your content directly—no file downloads required. +EmDash Exporter adds a guided migration wizard (Tools → EmDash Migration) and REST API endpoints that allow EmDash CMS to import your content directly—no file downloads required. **Features:** -* **One-click import** – Connect EmDash to your site and import everything -* **Full content** – Posts, pages, and custom post types including drafts +* **Guided wizard** – Site checks with actionable fixes, one-click migration key, honest overview of what will (and won't) be migrated +* **Migration key** – One copy-paste key contains everything EmDash needs to connect; no manual Application Password setup +* **Full content** – Posts, pages, and custom post types including drafts, with the original permalink for redirect maps * **Media with metadata** – Images, videos, and files with alt text, captions, and dimensions * **Custom fields** – Full ACF support plus any custom meta fields * **SEO data** – Yoast SEO and Rank Math meta automatically included * **Taxonomies** – Categories, tags, and custom taxonomies with hierarchy +* **Navigation menus** – Menus with hierarchy and theme locations +* **Translations** – WPML and Polylang locales and translation groups * **Authors** – User data for proper attribution +* **No EmDash site yet?** – Deploy a starter site to Cloudflare straight from the wizard **Authentication:** -Uses WordPress Application Passwords (built into WordPress 5.6+). Create an application password in your user profile, then use it with your WordPress username to authenticate API requests. +Uses WordPress Application Passwords (built into WordPress 5.6+). The wizard creates one for you and packages it into a single migration key. You can revoke it at any time. == Installation == -1. Upload the `emdash-exporter` folder to `/wp-content/plugins/` -2. Activate the plugin through the 'Plugins' menu in WordPress -3. Create an Application Password: Users → Your Profile → Application Passwords -4. In EmDash, enter your site URL and credentials to begin import +1. Download `emdash-exporter.zip` from the [latest release](https://github.com/emdash-cms/wp-emdash/releases) and install it via Plugins → Add New → Upload Plugin (or upload the `plugins/emdash-exporter` folder to `/wp-content/plugins/`) +2. Activate the plugin — you'll land in the migration wizard automatically +3. Follow the three steps: site check, generate your migration key, paste it in EmDash == API Endpoints == @@ -43,7 +46,11 @@ All endpoints are under `/wp-json/emdash/v1/` * `GET /probe` – Site info and capabilities -**Authenticated:** +**Public (no auth required):** + +* `GET /header-check` – Reports whether the Authorization header reaches WordPress (used by the wizard) + +**Authenticated (requires the `export` capability — Administrators and Editors):** * `GET /analyze` – Full site analysis for import planning * `GET /content?post_type=post` – Get posts (paginated) @@ -51,6 +58,7 @@ All endpoints are under `/wp-json/emdash/v1/` * `GET /media/{id}?include_data=true` – Get single media item with base64 data * `GET /taxonomies` – Get all taxonomies and terms * `GET /options` – Get site options +* `GET /menus` – Get navigation menus with items and hierarchy == Frequently Asked Questions == @@ -76,6 +84,15 @@ Full ACF support. Field groups are analyzed, and field values are exported with == Changelog == += 1.1.0 = +* Guided migration wizard under Tools → EmDash Migration with preflight site checks (permalinks, REST loopback, Authorization header, Application Passwords, reachability) +* One-click migration key: creates a revocable Application Password and packages URL + credentials into a single copy-paste string +* Deploy-to-Cloudflare shortcut for users without an EmDash site +* New `/menus` endpoint: navigation menus with hierarchy and theme locations +* Per-post `permalink` for SEO-preserving redirect maps +* WPML/Polylang support: per-post `locale` and `translation_group`, site-level `i18n` info in probe/analyze +* Security: export endpoints now require the `export` capability (previously `edit_posts` was enough), removed server file paths from media responses, hardened unserialization + = 1.0.0 = * Initial release * REST API endpoints for content, media, taxonomies, options From 8c7c63831d9bcc6865828009bf224a5c88f5439f Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:10:22 +0200 Subject: [PATCH 2/7] Wizard: note that the Cloudflare deploy button requires the Workers Paid plan The blog-cloudflare template ships a worker_loaders binding (Dynamic Workers, EmDash's plugin sandbox), which the Cloudflare API rejects with HTTP 400 on the Free plan (upstream emdash issues #31/#149). Warn users under the deploy button and point to the free-plan workaround. --- .DS_Store | Bin 0 -> 6148 bytes emdash-exporter.zip | Bin 0 -> 27683 bytes plugins/.DS_Store | Bin 0 -> 6148 bytes plugins/emdash-exporter/.DS_Store | Bin 0 -> 6148 bytes .../emdash-exporter/includes/views/wizard.php | 3 +++ 5 files changed, 3 insertions(+) create mode 100644 .DS_Store create mode 100644 emdash-exporter.zip create mode 100644 plugins/.DS_Store create mode 100644 plugins/emdash-exporter/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a00a0a6ba97657964d0e763caf9144a98d07c2d0 GIT binary patch literal 6148 zcmeHK%}N6?5T2<87X<0iV_uS<3MecAtZ)a~-q^J&C(Xxnu^tl?=c4>xb!>&ta5YV(b#@m|>S)h6;ggaiY@Krj#t z1Oq?H0BW{Kb72^LFc1s`1D_1Y`H;{Avtu#TqXV5j0f2IiR)MZ}SYQ$ZFgq4QSRibv zKucwRVz8ydo;)r)7DG!X)|D}jul(_NVZT9h(s1Hz7=17h3>-6XX2Y4(|0VuP)kpqu zN{oVmVBnuIz|(fsF7Q!aTTebuYHdQhMH7*@A_@e$mSUimVhrRQxl*V3lj!hg$6_e6 Sh+WfxaS>2Lq6-Fofq{3z3ou*& literal 0 HcmV?d00001 diff --git a/emdash-exporter.zip b/emdash-exporter.zip new file mode 100644 index 0000000000000000000000000000000000000000..7b84b7c001a31498726a3f9bb90c4b081a321c5f GIT binary patch literal 27683 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 t`4#hH-~1QM@A2xdm^k| z?){KEZcyVK+xu^^!3{l|)zxHn(Khx)vV8iU(X>@HU$t{a7OVS*$K~tu$xm4IFIelv z_U>M|Ojsxo3WNfoKq&B~0=%=;iUY%_LxE5r6!=g;&xgdKSUPrw`RHJkQvjkqqqDKC zxrB0(W9ir#(nAxY5{)XJVu;Z>o;=6sykDr;QK$kN5>kHv+8_!_NTt&Ket<7vVBuf*CI%#)&sJ$j zfeoR`u6*y}voFreDXxi#$E$KgG$5i3D%d-~utj8Dv?D#U$Rejb9%xzJj3%?DUUa*yQO=Y zQAH~%kxlz7ad_J1vzgD?`SGLW)7h(TK6CoXuay@^$;-tVa0Z-#U1I<>TO>PF^wAk` z2AqL41M+J;o z;l$Q_u-%!hL*aCH>>rXlajxj2GvExg8Q9ayfzQ7`}i literal 0 HcmV?d00001 diff --git a/plugins/emdash-exporter/includes/views/wizard.php b/plugins/emdash-exporter/includes/views/wizard.php index 2f9a6d7..4ad71a4 100644 --- a/plugins/emdash-exporter/includes/views/wizard.php +++ b/plugins/emdash-exporter/includes/views/wizard.php @@ -112,6 +112,9 @@ +

+ +

From 36fef2ad6d2eba49935773282818192d3509d910 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:10:41 +0200 Subject: [PATCH 3/7] Add .gitignore for OS junk and local test zip --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 2 ++ emdash-exporter.zip | Bin 27683 -> 0 bytes plugins/.DS_Store | Bin 6148 -> 0 bytes plugins/emdash-exporter/.DS_Store | Bin 6148 -> 0 bytes 5 files changed, 2 insertions(+) delete mode 100644 .DS_Store create mode 100644 .gitignore delete mode 100644 emdash-exporter.zip delete mode 100644 plugins/.DS_Store delete mode 100644 plugins/emdash-exporter/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index a00a0a6ba97657964d0e763caf9144a98d07c2d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}N6?5T2<87X<0iV_uS<3MecAtZ)a~-q^J&C(Xxnu^tl?=c4>xb!>&ta5YV(b#@m|>S)h6;ggaiY@Krj#t z1Oq?H0BW{Kb72^LFc1s`1D_1Y`H;{Avtu#TqXV5j0f2IiR)MZ}SYQ$ZFgq4QSRibv zKucwRVz8ydo;)r)7DG!X)|D}jul(_NVZT9h(s1Hz7=17h3>-6XX2Y4(|0VuP)kpqu zN{oVmVBnuIz|(fsF7Q!aTTebuYHdQhMH7*@A_@e$mSUimVhrRQxl*V3lj!hg$6_e6 Sh+WfxaS>2Lq6-Fofq{3z3ou*& diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..639751c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +emdash-exporter.zip diff --git a/emdash-exporter.zip b/emdash-exporter.zip deleted file mode 100644 index 7b84b7c001a31498726a3f9bb90c4b081a321c5f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27683 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 t`4#hH-~1QM@A2xdm^k| z?){KEZcyVK+xu^^!3{l|)zxHn(Khx)vV8iU(X>@HU$t{a7OVS*$K~tu$xm4IFIelv z_U>M|Ojsxo3WNfoKq&B~0=%=;iUY%_LxE5r6!=g;&xgdKSUPrw`RHJkQvjkqqqDKC zxrB0(W9ir#(nAxY5{)XJVu;Z>o;=6sykDr;QK$kN5>kHv+8_!_NTt&Ket<7vVBuf*CI%#)&sJ$j zfeoR`u6*y}voFreDXxi#$E$KgG$5i3D%d-~utj8Dv?D#U$Rejb9%xzJj3%?DUUa*yQO=Y zQAH~%kxlz7ad_J1vzgD?`SGLW)7h(TK6CoXuay@^$;-tVa0Z-#U1I<>TO>PF^wAk` z2AqL41M+J;o z;l$Q_u-%!hL*aCH>>rXlajxj2GvExg8Q9ayfzQ7`}i From a39bf91fdadf3f3e2bc061841adfba6b31eb24e2 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:04:04 +0200 Subject: [PATCH 4/7] Export comments and site identity; set plugin author to EmDash (1.2.0) - New /comments endpoint: approved and pending comments, paginated oldest-first, with author, email, plain-text body, UTC date, threading, and status. - /options now includes custom_logo_url and site_icon_url so EmDash can take over the site logo and favicon. - Wizard overview lists comments (with count) and site identity as included. --- plugins/emdash-exporter/emdash-exporter.php | 7 +- .../includes/class-admin-page.php | 1 + .../includes/class-comment-exporter.php | 77 +++++++++++++++++++ .../includes/class-rest-controller.php | 50 ++++++++++++ .../emdash-exporter/includes/views/wizard.php | 10 ++- plugins/emdash-exporter/readme.txt | 7 +- 6 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 plugins/emdash-exporter/includes/class-comment-exporter.php diff --git a/plugins/emdash-exporter/emdash-exporter.php b/plugins/emdash-exporter/emdash-exporter.php index a92887a..6cb0bf4 100644 --- a/plugins/emdash-exporter/emdash-exporter.php +++ b/plugins/emdash-exporter/emdash-exporter.php @@ -3,23 +3,24 @@ * Plugin Name: EmDash Exporter * Plugin URI: https://github.com/emdash-cms/wp-emdash * Description: Migrate your WordPress content to EmDash CMS with a guided wizard and one-click migration key - * Version: 1.1.0 + * Version: 1.2.0 * Requires at least: 5.6 * Requires PHP: 7.4 - * Author: Matt Kane + * Author: EmDash * License: GPL3 * Text Domain: emdash-exporter */ defined('ABSPATH') || exit; -define('EMDASH_EXPORTER_VERSION', '1.1.0'); +define('EMDASH_EXPORTER_VERSION', '1.2.0'); define('EMDASH_EXPORTER_PATH', plugin_dir_path(__FILE__)); require_once EMDASH_EXPORTER_PATH . 'includes/class-rest-controller.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-content-exporter.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-media-exporter.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-menu-exporter.php'; +require_once EMDASH_EXPORTER_PATH . 'includes/class-comment-exporter.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-i18n-exporter.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-health-check.php'; require_once EMDASH_EXPORTER_PATH . 'includes/class-migration-key.php'; diff --git a/plugins/emdash-exporter/includes/class-admin-page.php b/plugins/emdash-exporter/includes/class-admin-page.php index 6e45be6..5c2cfb0 100644 --- a/plugins/emdash-exporter/includes/class-admin-page.php +++ b/plugins/emdash-exporter/includes/class-admin-page.php @@ -178,6 +178,7 @@ private function content_overview() { 'taxonomies' => $taxonomies, 'media_count' => (int) wp_count_posts('attachment')->inherit, 'menu_count' => count(wp_get_nav_menus()), + 'comment_count' => EmDash_Comment_Exporter::count(), 'acf' => class_exists('ACF'), 'yoast' => defined('WPSEO_VERSION'), 'rankmath' => class_exists('RankMath'), diff --git a/plugins/emdash-exporter/includes/class-comment-exporter.php b/plugins/emdash-exporter/includes/class-comment-exporter.php new file mode 100644 index 0000000..eca10d5 --- /dev/null +++ b/plugins/emdash-exporter/includes/class-comment-exporter.php @@ -0,0 +1,77 @@ + ['approve', 'hold'], + 'type' => 'comment', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + 'number' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]; + + $comments = get_comments($args); + + $total = (int) get_comments(array_merge($args, [ + 'count' => true, + 'number' => 0, + 'offset' => 0, + ])); + + $items = []; + foreach ($comments as $comment) { + $items[] = [ + 'id' => (int) $comment->comment_ID, + 'post_id' => (int) $comment->comment_post_ID, + 'parent_id' => (int) $comment->comment_parent ?: null, + 'author_name' => $comment->comment_author, + 'author_email' => $comment->comment_author_email, + // EmDash renders comment bodies as escaped plain text + 'body' => wp_strip_all_tags($comment->comment_content), + // comment_date_gmt is "Y-m-d H:i:s" in UTC + 'date_gmt' => str_replace(' ', 'T', $comment->comment_date_gmt) . 'Z', + 'status' => $comment->comment_approved === '1' ? 'approved' : 'pending', + ]; + } + + return [ + 'items' => $items, + 'total' => $total, + 'pages' => $per_page > 0 ? (int) ceil($total / $per_page) : 1, + 'page' => $page, + 'per_page' => $per_page, + ]; + } + + /** + * Count exportable comments (approved + pending), for the wizard overview. + * + * @return int + */ + public static function count() { + return (int) get_comments([ + 'status' => ['approve', 'hold'], + 'type' => 'comment', + 'count' => true, + ]); + } +} diff --git a/plugins/emdash-exporter/includes/class-rest-controller.php b/plugins/emdash-exporter/includes/class-rest-controller.php index e7c3bc6..7eb6fa8 100644 --- a/plugins/emdash-exporter/includes/class-rest-controller.php +++ b/plugins/emdash-exporter/includes/class-rest-controller.php @@ -126,6 +126,25 @@ public function register_routes() { 'permission_callback' => [$this, 'check_permission'], ]); + register_rest_route(self::NAMESPACE, '/comments', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_comments'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'per_page' => [ + 'type' => 'integer', + 'default' => 500, + 'minimum' => 1, + 'maximum' => 500, + ], + 'page' => [ + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ], + ], + ]); + // Public: reflects whether the Authorization header reaches PHP. // Used by the wizard's loopback health check (some Apache/CGI setups // strip the header, which silently breaks Application Passwords). @@ -179,6 +198,17 @@ public function get_menus() { return $exporter->get_menus(); } + /** + * Get comments (approved + pending), paginated + */ + public function get_comments($request) { + $exporter = new EmDash_Comment_Exporter(); + return $exporter->get_comments( + $request->get_param('per_page'), + $request->get_param('page') + ); + } + /** * Probe endpoint - returns site info and capabilities * No authentication required @@ -234,6 +264,7 @@ public function probe() { 'taxonomies' => rest_url(self::NAMESPACE . '/taxonomies'), 'options' => rest_url(self::NAMESPACE . '/options'), 'menus' => rest_url(self::NAMESPACE . '/menus'), + 'comments' => rest_url(self::NAMESPACE . '/comments'), ], 'auth_instructions' => $this->get_auth_instructions(), ]); @@ -353,6 +384,25 @@ public function get_options() { $options[$key] = get_option($key); } + // Site identity media (Customizer logo + site icon), with resolved + // URLs so the importer can side-load the files. + $custom_logo = (int) get_theme_mod('custom_logo'); + if ($custom_logo) { + $logo_url = wp_get_attachment_url($custom_logo); + if ($logo_url) { + $options['custom_logo'] = $custom_logo; + $options['custom_logo_url'] = $logo_url; + } + } + $site_icon = (int) get_option('site_icon'); + if ($site_icon) { + $icon_url = wp_get_attachment_url($site_icon); + if ($icon_url) { + $options['site_icon'] = $site_icon; + $options['site_icon_url'] = $icon_url; + } + } + // Include Yoast settings if available if (defined('WPSEO_VERSION')) { $yoast_keys = [ diff --git a/plugins/emdash-exporter/includes/views/wizard.php b/plugins/emdash-exporter/includes/views/wizard.php index 4ad71a4..eebf6e3 100644 --- a/plugins/emdash-exporter/includes/views/wizard.php +++ b/plugins/emdash-exporter/includes/views/wizard.php @@ -113,7 +113,10 @@

- + +

+

+

@@ -142,6 +145,10 @@ 0) : ?>

  • + 0) : ?> +
  • + +
  • @@ -175,7 +182,6 @@ ); ?> -
  • diff --git a/plugins/emdash-exporter/readme.txt b/plugins/emdash-exporter/readme.txt index 65cb87b..3288d9a 100644 --- a/plugins/emdash-exporter/readme.txt +++ b/plugins/emdash-exporter/readme.txt @@ -4,7 +4,7 @@ Tags: export, migration, cms, emdash, astro Requires at least: 5.6 Tested up to: 6.4 Requires PHP: 7.4 -Stable tag: 1.1.0 +Stable tag: 1.2.0 License: GPL3 License URI: https://opensource.org/license/gpl-3.0 @@ -84,6 +84,11 @@ Full ACF support. Field groups are analyzed, and field values are exported with == Changelog == += 1.2.0 = +* New `/comments` endpoint: exports approved and pending comments (authors, dates, threading) for import into EmDash's native comment system +* Site identity in `/options`: custom logo and site icon with resolved URLs, so EmDash can take over title, tagline, logo, and favicon +* Wizard overview now lists comments and site identity as migrated + = 1.1.0 = * Guided migration wizard under Tools → EmDash Migration with preflight site checks (permalinks, REST loopback, Authorization header, Application Passwords, reachability) * One-click migration key: creates a revocable Application Password and packages URL + credentials into a single copy-paste string From 03c47db15cfa73f258b73ac651dc1fbb930fd47e Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:06:37 +0200 Subject: [PATCH 5/7] Export taxonomy post types and singular labels so EmDash can auto-create CPT taxonomies /taxonomies now includes each taxonomy's registered post types (object_type) and singular label. EmDash uses this to create matching taxonomy definitions scoped to the right collections during import, instead of skipping custom post type taxonomies as "missing". --- plugins/emdash-exporter/includes/class-rest-controller.php | 2 ++ plugins/emdash-exporter/readme.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/plugins/emdash-exporter/includes/class-rest-controller.php b/plugins/emdash-exporter/includes/class-rest-controller.php index 7eb6fa8..7b8d526 100644 --- a/plugins/emdash-exporter/includes/class-rest-controller.php +++ b/plugins/emdash-exporter/includes/class-rest-controller.php @@ -347,7 +347,9 @@ public function get_taxonomies() { $result[] = [ 'name' => $taxonomy->name, 'label' => $taxonomy->label, + 'label_singular' => $taxonomy->labels->singular_name ?? $taxonomy->label, 'hierarchical' => $taxonomy->hierarchical, + 'post_types' => array_values((array) $taxonomy->object_type), 'terms' => $term_data, ]; } diff --git a/plugins/emdash-exporter/readme.txt b/plugins/emdash-exporter/readme.txt index 3288d9a..942237c 100644 --- a/plugins/emdash-exporter/readme.txt +++ b/plugins/emdash-exporter/readme.txt @@ -88,6 +88,7 @@ Full ACF support. Field groups are analyzed, and field values are exported with * New `/comments` endpoint: exports approved and pending comments (authors, dates, threading) for import into EmDash's native comment system * Site identity in `/options`: custom logo and site icon with resolved URLs, so EmDash can take over title, tagline, logo, and favicon * Wizard overview now lists comments and site identity as migrated +* `/taxonomies` now includes each taxonomy's singular label and registered post types, so EmDash can auto-create custom post type taxonomies scoped to the right collections = 1.1.0 = * Guided migration wizard under Tools → EmDash Migration with preflight site checks (permalinks, REST loopback, Authorization header, Application Passwords, reachability) From d56bacd3ce40eba1deb99c9a2e11bac951013313 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:18:38 +0200 Subject: [PATCH 6/7] Drop release workflow from this branch (moved to #9 for independent review) --- .github/workflows/release.yml | 42 ----------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 91a3a1e..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Release plugin zip - -# Builds an installable emdash-exporter.zip (plugin folder at the archive -# root, so WordPress' "Upload Plugin" accepts it) on every tag push, and as -# a downloadable artifact on pull requests for testing. - -on: - push: - tags: - - "v*" - pull_request: - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: PHP syntax check - run: | - find plugins/emdash-exporter -name '*.php' -print0 | xargs -0 -n1 php -l - - - name: Build zip - run: | - cd plugins - zip -r ../emdash-exporter.zip emdash-exporter - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: emdash-exporter - path: emdash-exporter.zip - - - name: Create release - if: startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v2 - with: - files: emdash-exporter.zip - generate_release_notes: true From bf3539d8cbd9cf12b22a280a4febecc8830dffe9 Mon Sep 17 00:00:00 2001 From: swissky <30409887+swissky@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:35:10 +0200 Subject: [PATCH 7/7] Health check: probe the Authorization header with a custom scheme, not fake Basic credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from #8: a Basic header with dummy credentials triggers Application Password validation — if a user named 'emdash' exists the whole REST request 401s (false "header stripped" result), and security plugins can count it as a failed login. Bearer risks the same collision with JWT/OAuth plugins, so use "EmDashCheck header-check" — we only test whether the header string survives the server config. The endpoint now also falls back to getallheaders(): under mod_php the header can be visible to the Apache API without being mirrored into $_SERVER['HTTP_AUTHORIZATION'], which would have made the custom-scheme probe a false negative. --- plugins/emdash-exporter/includes/class-health-check.php | 8 +++++++- .../emdash-exporter/includes/class-rest-controller.php | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/emdash-exporter/includes/class-health-check.php b/plugins/emdash-exporter/includes/class-health-check.php index 326af19..f2cabd5 100644 --- a/plugins/emdash-exporter/includes/class-health-check.php +++ b/plugins/emdash-exporter/includes/class-health-check.php @@ -130,7 +130,13 @@ private function check_authorization_header() { $response = wp_remote_get(rest_url('emdash/v1/header-check'), [ 'timeout' => 10, 'sslverify' => false, - 'headers' => ['Authorization' => 'Basic ' . base64_encode('emdash:header-check')], + // Custom scheme on purpose: a Basic header with fake credentials + // would trigger Application Password validation (rejecting the + // whole REST request if an 'emdash' user exists) and can count as + // a failed login for brute-force protection plugins. A Bearer + // token could collide with JWT/OAuth plugins the same way. We + // only test whether the header string survives the server config. + 'headers' => ['Authorization' => 'EmDashCheck header-check'], ]); $label = __('Authorization header', 'emdash-exporter'); diff --git a/plugins/emdash-exporter/includes/class-rest-controller.php b/plugins/emdash-exporter/includes/class-rest-controller.php index 7b8d526..18a001e 100644 --- a/plugins/emdash-exporter/includes/class-rest-controller.php +++ b/plugins/emdash-exporter/includes/class-rest-controller.php @@ -183,6 +183,15 @@ public function header_check() { $header = $_SERVER['HTTP_AUTHORIZATION']; } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { $header = $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; + } elseif (function_exists('getallheaders')) { + // mod_php can expose the header via the Apache API only, + // without populating $_SERVER['HTTP_AUTHORIZATION']. + foreach (getallheaders() as $name => $value) { + if (strtolower($name) === 'authorization') { + $header = $value; + break; + } + } } return [