Changelog

All notable changes to the SL Migrations plugin are recorded here.

SL MigrationsUpdated 21 Oct 2026
changelogrelease-notesversion-history

CHANGELOG - SL Migrations

All notable changes to the SL Migrations plugin are recorded here. Follows the Keep a Changelog format.


[1.8.10] - 2026-10-18 (Fix: upgrade lost the SFTP/FTP storage settings)

Fixed

  • After upgrading to 1.8.9, one site could not push backups to its SFTP destination (the log showed an empty SFTP configuration, failing repeatedly). Cause: the site had no SL_MIGRATIONS_AUTH_KEY constant in wp-config, so the saved storage settings were encrypted with the legacy key (md5 of AUTH_KEY + DB_HOST + DB_NAME + DB_USER). The upgrade step (UpgradeFunctions::storeDupSecureKey()) saw the missing constant and immediately created a new random key without re-encrypting the old data, so every storage failed to decrypt and fell back to default values.
    • src/Core/Upgrade/UpgradeFunctions.php: on an upgrade where wp-config has no constant, it now writes the legacy key itself (createWpConfigSecureKey(false, true)); only on a fresh install does it create a random key. Sites that already have the constant are left alone (nothing is overwritten). Marked 2026-10-18 (TD, 1.8.10).
    • Affected sites: only the one without the constant (the other sites already had it). Recovered by writing the legacy key back into SL_MIGRATIONS_AUTH_KEY; the SFTP settings decrypt again and no data was lost.

[1.8.9] - 2026-10-18 (Security - backups can no longer be downloaded through a direct URL)

Security

  • Backups (*_archive.daf, which include the whole database) could be downloaded directly over HTTP if you knew the file name - wp-content/backups-slm/ sits inside the webroot and nginx did not block it; the log file name *_log.txt (downloadable) shares its base name with the archive, so the archive name could be inferred. Each backup stays local for a few hours before the remote copy is verified and it is deleted.
    • Server: added an nginx rule (in the shared or the per-site configuration) that blocks every file in backups-slm/, backups-dup-pro/ and backups-dup-lite/ (returns 404), leaving only *_log.txt open so the Xem log (View log) button and the Diagnostics → Log page in the admin keep working (logs contain no passwords).
    • Plugin: classes/package/class.pack.php (getLocalPackageFileURL(), Archive branch) - the Tải backup (Download backup) button in the admin now always uses getLocalPackageAjaxDownloadURL() (admin-ajax, HMAC-SHA256 token, expires after 1 hour) instead of the direct URL backups-slm/<archive>; it no longer depends on whether nginx blocks the folder. Marked // 2026-10-18 (TD).
  • The build/upload flow is unchanged: SLM does not make HTTP calls into backups-slm/ when creating a package; the upload to the remote destination goes over SFTP.

[1.8.8] - 2026-08-15 (Installer - fixed CREATE TABLE error when the default collation is empty, MariaDB 11.8)

Fixed (installer)

  • CREATE TABLE reported "syntax error near '' at line N" for tables using MariaDB 11.8's new utf8mb4_uca1400_ai_ci collation - installer/slm-installer/classes/database/class.db.functions.php (getDefaultCollateOfCharset()): when the source collation was treated as unsupported and the charset's default collation (charsetData[$charset]['defCollation']) came back EMPTY, QueryFixes::legacyCharsetAndCollation() replaced the source collation with an empty string → the statement became COLLATE= (no value) → MariaDB reported a syntax error and the table was not created (for example wp_po_plugins). Added a fallback: when defCollation is empty, return {charset}_general_ci (a universally valid collation) so it never produces an empty COLLATE=.

[1.8.7] - 2026-08-15 (Installer - fixed the password page getting stuck in a loop)

Fixed (installer)

  • Entering the correct password still did not pass the step - it stayed on the password screen (loop) - installer/slm-installer/src/Core/Params/Descriptors/ParamDescSecurity.php: the PARAM_SECURE_OK param (the "password verified" flag) was declared with 'persistence' => false. When the password was correct, Security::securityCheck() set PARAM_SECURE_OK = true and then called save(), but because persistence=false the value was not written to the params file → the next request reloaded the default false → getSecurityType() returned SECURITY_PASSWORD again → back to the password page forever. A WRONG password was still reported as wrong, as before (it was not a comparison bug). Changed to 'persistence' => true so the verification flag is kept across requests → entering the right password passes the step immediately. The bug lives in the installer embedded in the package → you need to create the backup package again so the new package uses the fixed installer.

[1.8.6] - 2026-06-30 (Installer UX - fixed the 404 at the end of a restore + Info page improvements)

Admin - Info page (Thông tin tab)

  • Paginated changelog - views/info/main.php + assets/css/info-page.css: the long changelog list is now split into 5 versions / page with a navigation bar (prev/next + page numbers), plain JS with no library dependency.
  • Server Timezone shows correctly - views/info/main.php: it used to show UTC (the server's PHP timezone). It now follows the WordPress setting through wp_timezone() → shows UTC+7 (with the region name if the site sets timezone_string).

Fixed (installer)

  • The installer reported a 404 / "ajax error" even though the restore had succeeded - installer/slm-installer/ctrls/classes/class.ctrl.s3.funcs.php (cleanupFiles()): the previous version hard-deleted the artifacts and ran rrmdir(DUPX_INIT), removing the whole slm-installer/ folder (including main.installer.php + the CSRF token) right at the STEP_CLEANUP_TMP_FILES chunk of step 3. But STEP_SET_FILE_PERMS, STEP_FINAL_REPORT_NOTICES and the step 4 final report still followed - each is a separate POST to main.installer.php → the server returned "Primary script unknown" (404) → the UI showed a red error even though the data had been fully restored.
    • Patch: removed the early self-destruct block. Deleting the installer files was moved to a phase with no race: MigrationMng::autoCleanFileAfterInstall() runs at the first admin login (PARAM_AUTO_CLEAN_INSTALLER_FILES / cleanInstallerFiles, ON by default) and deletes installer.php + slm-installer/ + the safe artifacts once no step needs them. Marked // NOTE (SLM 1.8.6).
  • Wrong installer display name - classes/package/class.pack.installer.php, installer/slm-installer/src/Bootstrap/BootstrapUtils.php, .../templates/default/pages-parts/boot-error/header.php: the brand name rendered as SL Migrationsfessional. Corrected to SL Migrations (this name is embedded in the archive when the package is built, so you must build a new package for it to take effect).

[1.8.5] - 2026-06-25 (Restore Fixes)

Fixed (restore)

  • Version check always failed - installer/slm-installer/src/Core/Security.php (~241): version_dup (1.8.x) !== DUPX_VERSION (placeholder 1.0.0) was always true → it reported "version of archive different from PHP scripts". Patch: added && false to drop the check (this is a self-packaged fork). Marked // SLM-FIX.
  • DB password was not persisted during validation - .../Core/Params/Descriptors/ParamDescDatabase.php (~181): PARAM_DB_PASS had 'persistence' => false → the DB validation request read an empty password → "Access denied … using password: NO". Patch: 'persistence' => true. Marked // SLM-FIX.

Both patches were already in the code (recorded in the patch notes file) but the version and CHANGELOG had not been bumped before - this entry brings them back in sync. The password gate (getSecurityType()) is unchanged.

[1.8.4] - 2026-06-01 (Branding - Asset Rebrand)

Branding - replaced every remaining visual asset left over from upstream

  • Email logo (assets/img/email-logo.png) - replaced with the SL Migrations logo: an icon of two blue squares + an arrow + the text "SL Migrations / BACKUP · RESTORE". The new logo was rendered with PHP GD + DejaVu Bold TTF, at 280×64 px.
  • Brand preview (assets/img/dpro-brand.png) - replaced with a demo image of the SL Migrations installer, 860×229 px (dark header, step wizard, form fields in the plugin's own style).

After this release no asset in the plugin carries the upstream's visual identity.


[1.8.3] - 2026-05-25 (WP 7.0 Compat - Merged Security + Performance Audit)

Fix - Download buttons broken on WordPress 7.0

  • HTML attribute escaping - wp_json_encode() inside onclick="..." produced unescaped " characters → a JS SyntaxError. Wrapped as esc_attr(wp_json_encode(...)) in:
    • slm-modern/views/_row.php - the Installer, Archive and Both buttons
    • slm-modern/views/detail.php - rewrote the download buttons to use onclick instead of data-action (there was no JS handler)
    • template/admin_pages/packages/row_parts/download_buttons.php - the legacy list view

Merge - Security Audit + Performance Pass (v1.8.2 web developer build)

Merged the whole security audit (6 findings), performance work (7 fixes), CSS minify and validation from the web developer's 1.8.2 build of 2026-05-20. See the [1.8.2] section below for details.

Compatibility

  • Tested up to: WordPress 7.0 (PHP 8.2.29)

[1.8.2] - 2026-05-20 (Security Audit Pass - Performance Pass - CSS Minify)

Security audit pass - 6 findings

A two-tier audit (security + performance) before release. All MUST and SHOULD security findings were fixed:

MUST FIX (Tier 2 HIGH):

  • Path traversal on the import upload src/Ajax/FileTransfer/ImportUpload.php:117-126 - $_FILES["file"]["name"] only went through the regex \.(zip|daf)$ + sanitizeNSCharsNewlineTabs (which does not strip ../). An admin could write a .zip outside SL_MIGRATIONS_PATH_IMPORTS. Fix: basename() before building the path.
  • CSRF on state-changing actions slm-modern/ModernPackagesController.php:96-159 + views/packages/main/packages.php:25-58 - create_from_temp, action=stop-build and action=clear-messages accepted GET/POST without a nonce → an admin could be lured into clicking a link that force-deletes a package. Fix: added wp_nonce_field('slm_modern_legacy_actions') to the 3 forms (s2.scan1.base.php × 2 + packages.php) + wp_verify_nonce on both entry paths (modern controller + legacy view).
  • Password leaking into the trace log - 3 places logged plaintext credentials:
    • src/Package/Import/PackageImporter.php:309 - archive password → now logs (length=N) instead of the raw value
    • classes/entities/class.package.template.entity.php:251 - installer password → same
    • src/Utils/Crypt/CryptBlowfish.php:189,243 - logged the raw $string (plaintext/ciphertext) when encrypt/decrypt failed → now logs only the length + the error message

SHOULD FIX (Tier 3 MEDIUM):

  • Latent SQLi in find_hash_key classes/package/class.pack.php:3038 - concatenated $hash straight into the query. There is no user input today, but to be future-proof: switched to $wpdb->prepare(... %s).
  • Self-XSS in the Recovery widget views/tools/recovery/widget/recovery-widget-selector.php:91-93 - $package['name'] was echoed unescaped → wrapped in esc_html() + cast id to (int).
  • Privilege escalation in quick_fix ctrls/class.web.services.php:650-672 - an endpoint with CAP_BASIC allowed setting ANY property of Global_Entity through $_POST['setup']['global'][key] → a CAP_BASIC user could change package_mysqldump_path, build_mode, etc. Fix: whitelisted 5 keys (ajax_protocol, basic_auth_*) that quick_fix may set.

Performance - 7 fixes

Tier 1, highest ROI:

  • is_pack_running 5 queries → 1 ctrls/class.web.services.php:1420-1490 - the old poller endpoint ran 5 separate queries (one per status range, fetching a full 11-column row blob each time). Consolidated into 1 unfiltered get_row_by_status() + partitioning by status in PHP. Saves 4 DB round-trips per poll tick.
  • Poll interval 2s → adaptive 5s-10s assets/js/slm-progress-poller.js - changed from setInterval(2000) to a setTimeout chain with progressive backoff: a signature built from status+progress+uploads.percent; if it does not change between 2 ticks → the delay grows ×1.5 up to a max of 10s; when there is movement → reset to 5s. A 10-minute build drops from ~300 ticks to ~120 ticks (60% fewer requests during static phases).
  • Cache the scan.json read src/Compat/slm-progress-api.php:165-200 - the file is a few MB and was read + JSON-parsed on every poll tick. Now cached with wp_cache_set keyed md5(path:mtime), namespace sl-migrations, TTL 600s. On a site with Redis (like this VPS) it hits 100% after the first tick. Saves 1 file_get_contents (a few MB) + 1 json_decode per tick.
  • Bootstrap init gate src/Core/Bootstrap.php:80-93 - ControllersManager + RESTManager are only instantiated in an admin/AJAX/cron/CLI/REST context. A WordPress frontend pageview no longer loads the plugin's classes and registers its hooks. Cold admin-area pageloads from the frontend instantiate several dozen fewer classes.
  • Autoloader memoize src/Utils/Autoloader.php:139-192 - customLegacyMapping() was called thousands of times per request by the autoload chain, building a 50-entry array each time. Memoized statically after the first call.
  • Duplicate queries in getTableCollationList + getTableEngineList classes/class.db.php:278-329 - getTableEngineList executed the query twice ($wpdb->query() and then $wpdb->get_col($query)). getTableCollationList used a pointless 2-step query+get_col() (the SELECT returned num_rows, which was unused). Both now use a single $wpdb->get_col($query), saving 1 round-trip per table chunk during the backup scan.
  • CSS bundle minify - the 10 main CSS files (style.css, slm.css, slm-overrides.css, packages.css, import.css, info-page.css, admin-notifications.css, admin-notices.css, global_admin_style.css, style-ctrl.css) were minified in place with clean-css-cli@5 -O2. Total down from 199KB → 145KB (27% smaller) - saves bandwidth on every admin page of the plugin.

Re-audit pass 2 - 5 new findings fixed

After applying the first 13 fixes, a second audit to verify them found more:

  • is_pack_running key mismatch (release blocker) ctrls/class.web.services.php:1441,1444 - after consolidating the 5 queries, the code pushed into cac_ids / err_ids but the arrays were initialized as cancel_ids / error_ids → the JS client received an empty array → lost the status. Fix: renamed back to cancel_ids / error_ids.
  • quick_fix whitelist typo ctrls/class.web.services.php:653-660 - it said ajax_protocol_custom but the real property is custom_ajax_url (class.global.entity.php:182) → the "Custom AJAX URL" quick-fix dialog failed silently. Fix: changed to custom_ajax_url.
  • OAuth token leaking in plaintext into the trace log src/Utils/OAuth/TokenEntity.php:108 - wp_json_encode($this) dumped the plaintext access_token + refresh_token when a refresh failed. The trace log is readable by CAP_BASIC users through wp_ajax_sl_migrations_get_trace_log → token exposure. Fix: log only the storage_type + expired status.
  • Missing CAP_CREATE check for create_from_temp slm-modern/ModernPackagesController.php:107 + views/packages/main/packages.php:33 - the nonce was fine but there was no capability check → a CAP_BASIC user could create a package from temp data. Fix: added a CapMng::can(CapMng::CAP_CREATE) gate.
  • renameInstallerFile ran on every frontend pageview src/Core/Bootstrap.php:75 - the init hook at priority 20 ran ExpireOptions::getUpdate (a transient lookup + DB write) on every frontend pageview even when not needed. Fix: wrapped in the $isContextual gate together with ControllersManager/RESTManager.

Files changed

Security:

  • src/Ajax/FileTransfer/ImportUpload.php - basename path traversal
  • slm-modern/ModernPackagesController.php - CSRF nonce + CAP_CREATE
  • views/packages/main/packages.php - CSRF nonce + CAP_CREATE
  • views/packages/main/s2.scan1.base.php - wp_nonce_field
  • src/Package/Import/PackageImporter.php - no longer logs the archive password
  • classes/entities/class.package.template.entity.php - no longer logs the installer password
  • src/Utils/Crypt/CryptBlowfish.php - no longer logs plaintext when crypt fails
  • src/Utils/OAuth/TokenEntity.php - no longer dumps the OAuth token in plaintext
  • classes/package/class.pack.php - find_hash_key prepare
  • views/tools/recovery/widget/recovery-widget-selector.php - esc_html on the package name
  • ctrls/class.web.services.php - quick_fix whitelist + custom_ajax_url

Performance:

  • ctrls/class.web.services.php - is_pack_running 1 query + correct response keys
  • assets/js/slm-progress-poller.js - adaptive poll backoff
  • src/Compat/slm-progress-api.php - cache scan.json
  • src/Core/Bootstrap.php - context gate + renameInstallerFile
  • src/Utils/Autoloader.php - memoize mapping
  • classes/class.db.php - removed the duplicate query
  • 10 assets/css/*.css files minified (27% smaller)

Serious fix - SFTP credentials lost and Default storage retention reset after every update

Symptom: after updating the plugin, some sites lost their SFTP credentials completely (server/username/password empty), and every site had its Default storage reset: max_packages went from the value you had set (for example 1) back to 20, and the "automatically delete old packages" option (purge_packages) was unchecked.

Root cause: src/Core/Upgrade/UpgradeFunctions.php:258-265 - the storeDupSecureKey() function compared $currentVersion with '4.5.0' (a leftover from upstream Duplicator Pro). The plugin has been rebranded to the 1.x.x scheme → version_compare('1.x.x', '4.5.0', '<=') is always TRUE → every update fell into the branch:

CryptBlowfish::createWpConfigSecureKey(true, true); // OVERWRITE=true, legacy MD5

→ It overwrote SL_MIGRATIONS_AUTH_KEY in wp-config with the legacy MD5 hash → all the config already encrypted in the DB (SLM_PRO_Storage_Entity.config) failed to decrypt → AbstractStorageEntity::__wakeup fell back to getDefaultConfig():

  • Default storage (Local/DefaultLocalStorage.php:49-50): max_packages=20, purge_packages=false - every site was hit because every site has a Default storage entity.
  • SFTP storage (addons/ftpaddon/src/Models/SFTPStorage.php:37-54): password='', username='', server='' - only sites with a previously configured SFTP were hit.

Fix: removed all of the legacy Duplicator Pro 4.x migration from src/Core/Upgrade/UpgradeFunctions.php - the plugin is standalone now and no user runs upstream any more:

  • storeDupSecureKey() - removed the legacy MD5 overwrite branch; it now only ensures the key exists
  • updateStorages() - removed entirely (it called updateFromGlobal(), which reset max_packages=20, purge_packages=false on every update → the root cause of the Default storage reset on every site)
  • updateTemplates(), updatePackageComponents(), initEmailSummaryRecipients() - removed entirely (the same 4.5.x version-compare bug pattern)
  • renameLegacyTablesName() - removed entirely (renamed sl_migrations_* tables to the new names; the 1.x fork has used the new names from the start)
  • moveDataToSecureGlobal(), moveStorageSettingsToDynamicEntity() - removed entirely (migrations of global → secure global / dynamic entity that finished long ago)
  • The constants LAST_VERSION_*, FIRST_VERSION_WITH_NEW_TABLES, LEGACY_DB_* - deleted
  • DefaultLocalStorage::updateFromGlobal() - deleted too (orphaned, nothing calls it)

performUpgrade() now does only 2 things: init the DB schema + ensure SL_MIGRATIONS_AUTH_KEY exists. Updating the plugin no longer touches the encrypted config in the DB.

The footer on the page_sl-migrations pages used to have left: 160px (sidebar expanded) / left: 36px (folded) + a mobile media query left: 0. It is now a fixed left: 0 for every sidebar state → the footer is consistently full width.

Security - Handlebars 4.0.10 → 4.7.8

The assets/js/handlebars.min.js bundle was at 4.0.10 - affected by CVE-2019-19919 (RCE through prototype pollution in the template compiler) and other RCE/XSS chains in 4.0.x-4.4.x. Upstream Duplicator Pro upgraded in 4.5.25 with the note "JavaScript security vulnerabilities (Handlebars RCE/prototype-pollution)".

Replaced with Handlebars 4.7.8 (the last security patch of the 4.x line). The registerHelper + compile API in use at assets/js/javascript.php, views/packages/main/s2.scan3.archive.php and views/tools/diagnostics/inc.validator.php is backward compatible.

Storage form validation - prevents the storage list from crashing

Before, if a user pressed Save Provider with the server / username fields empty, the storage entity was still saved with an empty config → the Storage list page then rendered getLocationString() on the empty config → 500 / unusable page (the same symptom fixed in DupPro 4.5.24.4).

Server-side validation in FTPStorage::updateFromHttpRequest and SFTPStorage::updateFromHttpRequest:

  • FTP: server + username must not be empty
  • SFTP: server + username must not be empty, and at least one of password / private_key must exist (already saved or just entered)

It blocks the save and returns a clear Vietnamese message if anything is missing.

Defensive fixes

  • MySQL NULL table stats (class.pack.database.php:229) - DATA_LENGTH + INDEX_LENGTH on InnoDB can return NULL when statistics have not been computed yet → adding NULL gives NULL → a fatal error when cast to int in the scan loop. Wrapped both columns in COALESCE(..., 0).
  • Trace log download when fpassthru is disabled (ctrls/class.web.services.php:1058) - fpassthru is disabled on some shared hosts through disable_functions. It now falls back to reading in 8KB chunks if fpassthru does not exist or returns false.

Files changed

  • assets/js/handlebars.min.js - Handlebars 4.7.8 (security)
  • addons/ftpaddon/src/Models/FTPStorage.php - validate server + username
  • addons/ftpaddon/src/Models/SFTPStorage.php - validate server + username + (password OR private_key)
  • classes/package/class.pack.database.php - COALESCE for NULL table stats
  • ctrls/class.web.services.php - chunked-read fallback when fpassthru is disabled
  • src/Core/Upgrade/UpgradeFunctions.php - rewritten down to 3 methods (performUpgrade, updateDatabase, storeDupSecureKey)
  • src/Core/MigrationMng.php - calls updateDatabase() with no parameters
  • src/Models/Storages/Local/DefaultLocalStorage.php - deleted the orphaned updateFromGlobal()
  • slm-modern/modern.php - simplified the inline critical CSS for the footer

[1.8.0] - 2026-05-18 (Modern Recovery Point UI - Modal Escape Fix - Import Min Version)

Complete Recovery Point UI in the Modern UI

Before, Set Recovery Point existed only in the Hamburger menu of the legacy UI → the modern UI lacked the action entirely. This version completes the experience:

Backend AJAX endpoints (same slm_modern nonce):

  • slm_modern_recovery_status - reads the current state (read-only)
  • slm_modern_recovery_set - wraps RecoveryPackage::setRecoveablePackage()
  • slm_modern_recovery_reset - wraps SLM_PRO_CTRL_recovery::actionResetRecoveryPoint()

Frontend:

  • Tools → Recovery tab: a modern "Recovery Point" panel ABOVE the legacy one with a live status dot, a "Recovery Point hiện tại" (Current Recovery Point) box (name + recovery link + Reset button), and a package dropdown + a "Set as Recovery Point" button
  • Packages list: a shield icon in the row actions (only shown when isPackageIdRecoveable() is true and the package is not already the current Recovery Point)
  • Package detail page: a "Set as Recovery Point" button under "Hành động" (Actions); a "Recovery Point" badge next to the status pill once set

Visual identity:

  • The "Recovery Point" badge has a blue gradient #3b82f6 → #1d4ed8 with a box-shadow ring
  • The row in the Packages list is highlighted with inset 3px 0 0 #2563eb (a blue left border) + a light gradient background
  • Status dot with 3 states: blue (set) / orange (unset) / red (error)
  • The "Recovery Point" (modern) + "Disaster Recovery" (legacy) headings are synced at font-size: 18px, weight: 700, icon 20×20; the shield icon is blue and the fire icon is green

Protecting the Recovery Point from data loss

Before, the retention cron AbstractStorageEntity::purgeOldPackages() only sorted by modified time and cut the oldest N packages according to max_packages. If the package serving as the Recovery Point was older than the newer packages, it was permanently deleted → the recovery URL broke → you lost the ability to recover when you needed it.

Fix: purgeOldPackages() looks up RecoveryPackage::getRecoverPackageId() → loads the Package object → archive_name → compares it against every file in the loop. A matching file is skipped (logged as "Skipping purge: package is current Recovery Point").

Manual deletion is blocked too:

  • Client-side: the Delete button in the row and the detail page is disabled with the tooltip "Không thể xoá - đang là Recovery Point. Reset Recovery Point trước." (Cannot delete - this is the current Recovery Point. Reset the Recovery Point first.)
  • Server-side guard: ajaxDelete rejects with HTTP 409 + a Vietnamese message if the request has the ID of the current Recovery Point (blocking a UI bypass).

Fix: IMPORT_ENABLE_MIN_VERSION too high → every package rejected

PackageImporter::IMPORT_ENABLE_MIN_VERSION was fixed at '4.0.0' (a leftover from upstream Duplicator Pro). sl-migrations was rebranded to the 1.x scheme → version_compare('1.7.x', '4.0.0', '<') = true → RecoveryStatus::isRecoveable() rejected every package → getRecoverablesPackages() always returned an empty array → a Recovery Point could never be set.

Lowered the threshold to '1.0.0' (the same as IMPORT_SUB_SITE_IN_MULTISITE_MIN_VERSION and IMPORT_BRIDGE_MIN_VERSION).

Clicking a package name in the Dashboard widget → the detail page said "Gói không tồn tại". Root cause: PackagesPageController::getPackageDetailsUrl() built the URL with the key id, but slm-modern/views/detail.php only read $_GET['package_id'].

Fix: detail.php accepts both keys - package_id first (modern), falling back to id (legacy widget).

Fix: raw &quot; in modals (global UI)

SLM.modal.confirm({body: ...}) displayed a literal &quot; instead of a quote mark because of double escaping: escapeHtml() turned " → &quot;, and the result was then wrapped as a textNode (the browser does not decode entities in a textNode).

Fix: assets/js/slm-ui.js line 73 uses el('div', {text: opts.body}) (the textContent path) instead of wrapping escapeHtml(body) in a textNode. This bug affected EVERY modal in the plugin, not just Recovery.

Cache version sync

SLM_MODERN_VER changed from the hardcoded '1.7.3' to SL_MIGRATIONS_VERSION → every time the plugin version is bumped, modern.css + modern.js are cache-busted automatically in the browser. Before, users had to hard refresh or wait for the browser cache TTL.

Files changed

  • slm-modern/: ModernPackagesController.php, ModernToolsController.php, views/_row.php, views/detail.php, modern.js, modern.css, modern.php
  • src/Models/Storages/AbstractStorageEntity.php - purge skips the Recovery Point
  • src/Package/Import/PackageImporter.php - lowered IMPORT_*_MIN_VERSION
  • assets/js/slm-ui.js - fixed the modal escape bug

Symptom: clicking a package name in the Dashboard widget → the detail page said "Gói không tồn tại - Có thể gói đã bị xóa hoặc ID không hợp lệ" (Package does not exist - it may have been deleted or the ID is invalid).

Root cause: a query key mismatch between the legacy URL generator (getPackageDetailsUrl() passes id={ID}) and the modern detail view (detail.php only reads $_GET['package_id']). ID = 0 → SLM_PRO_Package::get_by_id(0) returns null → "Not found".

Fix: slm-modern/views/detail.php reads both keys - package_id first (modern), falling back to id (the legacy widget and other places not yet migrated to the new URL pattern).

Modern Recovery Point - set/reset from the new UI

Before, "Set Recovery Point" was only reachable through the Hamburger menu on a package row in the legacy UI. The modern UI lacked this action entirely → users had to go to the Recovery tab of Tools, read the instructions, and then go to the old Packages page to do it.

Added:

  1. A shield button in the row actions (Packages list - modern) - only shown when the package is eligible (it has both an Archive + an Installer). Click → confirm modal → AJAX set → toast.

  2. A "Recovery Point" panel in the Tools → Recovery tab (modern) - rendered ABOVE the legacy content, consisting of:

    • A status dot (set/not set) loaded automatically through slm_modern_recovery_status
    • A "Recovery Point hiện tại" (Current Recovery Point) block + recovery link + a Reset button (shown once set)
    • A package dropdown + a "Set as Recovery Point" button
    • An empty state when no package is eligible yet

Backend wrappers in ModernPackagesController (a file that already has the slm_modern nonce pattern):

  • wp_ajax_slm_modern_recovery_status - reads the state, non-destructive
  • wp_ajax_slm_modern_recovery_set - wraps RecoveryPackage::setRecoveablePackage()
  • wp_ajax_slm_modern_recovery_reset - wraps SLM_PRO_CTRL_recovery::actionResetRecoveryPoint()

All endpoints use the same existing slm_modern nonce - no new nonce key needs to be added to the global config.


Fixes

  • Timezone shown wrongly on the Packages list: the Created column showed UTC time instead of Vietnam time (for example a schedule ran at 12:00 ICT but showed 05:00). Root cause: date_i18n() was used - a legacy API that treats the timestamp as already offset and shows raw UTC. Fix: switched to wp_date() (the modern API that respects the WP timezone) + parsing the string with a UTC suffix so strtotime understands it correctly. (ModernPackagesController.php:217-230)

  • Sticky footer + FOUC on Packages/Schedules/Storage/Tools: many iterations to achieve:

    • The footer pins to the bottom of the viewport on short-content pages
    • No extra scrolling
    • No layout flash (the logo "jumping" from the middle to the top when the CSS loads)
    • The footer aligns correctly with the sidebar's right edge (160px default / 36px folded / 0 mobile)
    • The back-to-top button (#backtotop from the wp-extra plugin) floats above the footer strip instead of being covered

    Final approach: position: fixed for #wpfooter (inlined into <head> through the admin_head hook at priority 1) + padding-bottom: 60px for #wpbody-content. Dropped the flex approach on #wpwrap because while the WP core CSS has not loaded yet, the sidebar #adminmenuwrap becomes an in-flow flex item → pushes the content down → flash.

Enhancements

  • Plugin row meta links (Bootstrap.php metaLinks): added 2 links, README + Changelog, to the meta line under the plugin row (next to "Version - By Steven Lam - Visit plugin site - Help"). All of them point to the documentation on phatdev.net (target="_blank"):
    • README → phatdev.net/docs/sl-migrations/getting-started
    • Changelog → phatdev.net/docs/sl-migrations/changelog (the article is seeded from the CHANGELOG.md file)
    • Help → phatdev.net/docs/sl-migrations
    • Visit plugin site → phatdev.net/sl-migrations (the landing page)
  • Tools → Support tab: added a 3rd card "Tài liệu plugin" (Plugin documentation) with README + Changelog links (target blank, opens phatdev.net in a new tab).
  • New landing page: /sl-migrations on phatdev.net - a complete, sale-ready marketing page for when the plugin goes on sale later. It includes a hero (gradient text + CTA), 6 feature cards, 3 use cases (Agency/DevOps/Production), tech specs, a pricing section (Free internal), a bottom CTA and a footer.
  • New doc seed script (seed-docs-sl-migrations-changelog.mjs): an idempotent script that pushes the CHANGELOG.md content into phatdev.net's docs DB (table docs_articles), auto-extracting the latest version + date.

Technical

  • SL_MIGRATIONS_VERSION, SLM_ASSETS_VER, SLM_MODERN_VER → 1.7.3
  • Plugin URI changed from https://phatdev.net/ → https://phatdev.net/sl-migrations
  • Inline critical CSS in <head> through admin_head priority 1 - no extra HTTP request, applied from the very first frame

[1.7.2] - 2026-05-18 (Run Now Redirect - Import Settings Hardening - Layout & Docs)

Fixes

  • Layout - sticky footer: on the Packages/Schedules/Storage/Tools pages with short content, #wpbody-content was forced to min-height: calc(100vh - 100px), plus #wpadminbar (32px) + #wpfooter (~52px) → the total exceeded 100vh → extra vertical scroll and the footer pushed below the viewport. Fix: use a flex column on #wpbody (min-height: calc(100vh - 32px) on desktop / 46px on mobile) with flex: 1 0 auto for the content and flex: 0 0 auto for the footer → the footer always pins to the bottom of the viewport, the content grows to fill the gap, and no scroll is generated.
  • Back-to-top button overlap: the #backtotop of the wp-extra plugin is injected inline with bottom: 15px; once the footer was pinned to the bottom, this button was covered by the footer. Fix: override bottom: 70px !important only within the scope body[class*="page_sl-migrations"] so the button floats just above the footer strip.
  • Schedules - Run Now: the dialog got stuck on "Running Schedule, Please Wait..." and did not jump to the Packages tab on click. Root cause: .done() does not fire when PHP-FPM/Nginx resets the connection (HTTP/2 reuse). Fix: switched to a fire-and-forget pattern - dispatch the AJAX call then redirect after 300ms; the browser still finishes sending the request before navigating. Also removed the .always() + the long 12s safety timer from the earlier fix.
  • Import Settings - cross-site .dup files: importing a .dup file from another Duplicator Pro / SL-Migrations site could lose config when the storage is a subclass (SFTP, S3, ...) because ReflectionClass(self::class) only picks up the properties of AbstractStorageEntity and misses the subclass properties. Fix: use static::class so late static binding picks the right runtime class.
  • Import Settings - robustness: MigrateSettings::importStorages/Templates/Schedules/Settings had no try/catch, so one malformed entity brought down the whole import. Fix: wrap each item in try/catch + validate is_array($data) + check isset($data['id']) before mapping the id.
  • AbstractStorageEntity::__wakeup(): an implicit isset(null[$key]) when CryptBlowfish::decrypt() fails - silently skipped, with the config going back to default and no log. Fix: an explicit is_array($config) guard + a fallback of [].
  • AbstractStorageEntity::settingsImport(): validate that $data is an array, force config to always be an array (if the source export has the wrong structure), and wrap setValue() in try/catch so one bad property does not break the entity.

Enhancements

  • MigrateSettings::decodeImportPayload() is a new helper: it tries to decrypt the standard format (CryptCustom('test') - compatible with Duplicator Pro exports); if that fails it falls back to plain JSON. This raises the rate at which .dup files from different versions / sources can be read.

Technical

  • SL_MIGRATIONS_VERSION, SLM_ASSETS_VER → 1.7.2
  • Files changed: template/admin_pages/schedules/schedule_list.php, src/Utils/Settings/MigrateSettings.php, src/Models/Storages/AbstractStorageEntity.php

Verified

  • Smoke test on 2 real .dup files (a dpro-export from reviewlaptop.vn and tandoanh.vn): decrypt OK, parse OK, and the full SFTP credentials (server / port / username / password / private_key / timeout / chunking) are imported through the SFTPStorage subclass.

[1.7.1] - 2026-05-17 (CSS Fixes - Template Edit Page)

Fixes

  • Template Edit - Filters section: .section-title { height: 20px; padding: 10px } with WP admin box-sizing: border-box produced a content area of 0px → the "Enable" checkbox was clipped and the filter links overlapped. Fix: override height: auto; min-height: 44px; align-items: center.
  • Template Edit - Installer → Branding row: the <select> had min-height: 40px while the "Preview" button had min-height: 38px → the heights did not match. Fix: synced both to height: 38px.
  • Filter textareas: the global textarea { min-height: 100px } in slm-overrides.css made #filter-paths and #filter-exts too tall and caused layout overflow. Fix: a dedicated scope with min-height: 60px; height: 80px.

Technical

  • SLM_ASSETS_VER, SLM_MODERN_VER, SL_MIGRATIONS_VERSION → 1.7.1
  • CSS fixes in assets/css/slm-overrides.css

[1.7.0] - 2026-05-14 (Language Policy - Modern UI - Table Layouts)

Language policy

  • Short labels / tabs / buttons all moved to English (Packages, Schedules, Storage, Tools, Settings, Info, Apply, Save Schedule, Activated, Refresh, Reset, ...)
  • Vietnamese is kept only for long descriptions, tooltips, error messages, warnings and confirmation prompts
  • Consistent form of address in all user-facing strings: only "bạn" (the Vietnamese informal "you")
  • translations-vi.php trimmed from ~996 lines to ~110 lines, keeping 40 long explanatory entries

UI Redesign

  • Modern UI hero titles all switched to English: Packages / Import / Schedules / Storage / Tools / Settings
  • Disaster Recovery card layout completed: a red header when not set up, green when set, a house-fire icon in a circle, and a description that uses "bạn"
  • Storage table: columns split 10% / 30% / 60% (checkbox / Name / Type)
  • Schedule table: checkbox 10% + the other 6 columns 15% each (90÷6)
  • Packages table: 10% checkbox + 22% Name + 14% Status + 18% Download + 16% Created + 10% Size + 5% Restore + 5% Details
  • Modern UI Packages: added a Download column with 3 icon buttons (Installer / Archive / Both Files) + fixed the Created field to display d/m/Y H:i correctly
  • Table styling synced across all 3 list tables: a light grey header + white body + alternating #fafbfd + a light blue hover
  • "No Schedules Found / Create New Schedule" is centered instead of left-aligned
  • Bulk Actions select synced to a 44px height with the Apply / Add New buttons (max-height + zero vertical padding to override the browser's default <select> rendering)
  • Trace log widget positioned exactly at right: 60px; bottom: 12px, with 4 buttons (View / Clear / Download / Turn Off) in one row

Quality of Life

  • Hid the "Screen Options" panel on the Packages tab (the modern UI has no column toggle, so that panel only added clutter)
  • The form wrappers #dup-storage-form, #form-sl_migrations and #dup-schedule-form are forced to display: block; width: 100% so tables stretch to the full card width
  • Tables use table-layout: fixed + width: 100% on the 3 list tables → the column percentages are honored exactly
  • Hidden columns (dup-note-column, dup-storages-column, dup-age-column) are display: none so the table is not "cramped"

Technical

  • SLM_ASSETS_VER → 1.7.0, SLM_MODERN_VER → 1.7.0 (synced with the plugin version)
  • ModernPackagesController::normalisePackage() prefers the getCreated() method and adds 6 download fields (installer_url, archive_url, installer_exists, archive_exists, archive_filename, archive_format)
  • add_filter('screen_options_show_screen', ...) hides screen options on toplevel_page_sl-migrations

[1.6.0] - 2026-05-14 (Info Tab - Disaster Recovery - UI Polish)

Added

  • Thông tin (Info) tab: a plugin introduction page with a blue gradient hero, 6 feature highlight cards, an automatic CHANGELOG.md parser, and a system information table (WP/PHP/MariaDB version, memory, timezone, plugin version)
  • 3 separate download buttons on the packages list: Download PHP Installer - Download Archive (DAF/ZIP) - Download both files (replacing the dropdown menu)
  • The Tải về (Download) column moved to right after the Trạng thái (Status) column instead of the end of the table

UI Redesign

  • Disaster Recovery wrapped in a rounded white card, with a red header when not set up / green when set, and a house-fire icon in a circle
  • The ajax-loader spinner changed from a rotating Duplicator logo to a Font Awesome circle icon (fa-circle-notch fa-spin)
  • Button style sync: the plain .button, .button.action, .button-primary and .button-secondary all follow the "big pill" style, 9px×18px, min-height 38px
  • Compact trace log widget: the View/Clear/Download/Turn off buttons shrunk to fit the card, with roomier container padding
  • Storage list table: the checkbox column narrowed to 40px (before, the rule td:first-child{width:240px} from the Server Info table leaked into it and pushed the Name column far from the left margin)
  • Schedule edit form: the Package Template select and the 3 icon buttons (edit, add, refresh) are all 36px tall, and the Start Time select 00:00 is vertically centered
  • Hid the Note + Storages + Age columns on the packages list so Status sits next to the package name

Vietnamese translation

  • Added ~50 translated strings: Disaster Recovery (Khôi phục sự cố, Tuổi sao lưu, Bản khôi phục sự cố...), the Schedule form (Kích hoạt, Bật lịch trình này, Lưu lịch trình, Mẫu gói sao lưu, Thời gian bắt đầu...), and the Packages download buttons
  • Refactored multi-line _e() calls into inline Vietnamese to avoid whitespace mismatches in the gettext overlay

Branding

  • Changed the Author header: Phat Lam → Steven Lam in sl-migrations.php and README.md
  • Cleaned up legacy copyright: rewrote 371 files from @copyright (c) 2022, → @copyright (c) 2022-2026, Steven Lam
  • Removed Snap Creek references: replaced @author <admin@snapcreek.com> → @author Steven Lam <info@phatdev.net>, and changed snapcreek.com → phatdev.net across the installer and library files

Technical

  • SLM_ASSETS_VER bumped to 1.6.0 to bust the browser cache
  • New: src/Controllers/InfoPageController.php (menuPos=90, CAP_BASIC) - views/info/main.php - assets/css/info-page.css
  • New: INFO_SUBMENU_SLUG = 'sl-migrations-info' in ControllersManager

[1.0.0] - 2026-05-13 (Initial Release)

First release

  • Backs up an entire WordPress site (files + database) into a single archive with a PHP installer
  • One-click restore on any compatible WordPress host
  • Scheduled backups (hourly/daily/weekly/monthly)
  • Secure remote storage over FTP / SFTP
  • Default local storage at wp-content/backups-slm/
  • Plugin namespace SLMigrations\ (PSR-4) with the composer-scoped vendor namespace VendorSLMigrations\
  • Constants SL_MIGRATIONS_* / SLM_PRO_*; hooks sl_migrations_*; text domain sl-migrations
  • Installer engine at installer/slm-installer/

Security

  • No outbound network traffic during normal operation
  • No license activation, no telemetry, no remote update check
  • The plugin has Update URI: false - WordPress will not call out to a server for updates

Branding

  • Applied the TanDoanh Plugins design system (a neutral blue palette, rounded cards, light shadows)
  • The logo and admin CSS overrides ship with the plugin

Notes

  • Supports WordPress single-site only (Network: false)
  • Cloud storage (Google Drive / OneDrive / Dropbox / S3) is not in this release
  • Cloud support will come in the future through a self-hosted OAuth flow (the operator supplies their own Client ID / Secret)