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_KEYconstant in wp-config, so the saved storage settings were encrypted with the legacy key (md5 ofAUTH_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). Marked2026-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/andbackups-dup-lite/(returns 404), leaving only*_log.txtopen 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 usesgetLocalPackageAjaxDownloadURL()(admin-ajax, HMAC-SHA256 token, expires after 1 hour) instead of the direct URLbackups-slm/<archive>; it no longer depends on whether nginx blocks the folder. Marked// 2026-10-18 (TD).
- Server: added an nginx rule (in the shared or the per-site configuration) that blocks every file in
- 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_cicollation -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 becameCOLLATE=(no value) → MariaDB reported a syntax error and the table was not created (for examplewp_po_plugins). Added a fallback: whendefCollationis empty, return{charset}_general_ci(a universally valid collation) so it never produces an emptyCOLLATE=.
[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: thePARAM_SECURE_OKparam (the "password verified" flag) was declared with'persistence' => false. When the password was correct,Security::securityCheck()setPARAM_SECURE_OK = trueand then calledsave(), but becausepersistence=falsethe value was not written to the params file → the next request reloaded the defaultfalse→getSecurityType()returnedSECURITY_PASSWORDagain → 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' => trueso 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 showUTC(the server's PHP timezone). It now follows the WordPress setting throughwp_timezone()→ showsUTC+7(with the region name if the site setstimezone_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 ranrrmdir(DUPX_INIT), removing the wholeslm-installer/folder (includingmain.installer.php+ the CSRF token) right at theSTEP_CLEANUP_TMP_FILESchunk of step 3. ButSTEP_SET_FILE_PERMS,STEP_FINAL_REPORT_NOTICESand the step 4 final report still followed - each is a separate POST tomain.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 deletesinstaller.php+slm-installer/+ the safe artifacts once no step needs them. Marked// NOTE (SLM 1.8.6).
- Patch: removed the early self-destruct block. Deleting the installer files was moved to a phase with no race:
- 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 asSL Migrationsfessional. Corrected toSL 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&& falseto 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_PASShad'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()insideonclick="..."produced unescaped"characters → a JS SyntaxError. Wrapped asesc_attr(wp_json_encode(...))in:slm-modern/views/_row.php- the Installer, Archive and Both buttonsslm-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.zipoutsideSL_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-buildandaction=clear-messagesaccepted GET/POST without a nonce → an admin could be lured into clicking a link that force-deletes a package. Fix: addedwp_nonce_field('slm_modern_legacy_actions')to the 3 forms (s2.scan1.base.php× 2 +packages.php) +wp_verify_nonceon 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
- src/Package/Import/PackageImporter.php:309 - archive password → now logs
SHOULD FIX (Tier 3 MEDIUM):
- Latent SQLi in
find_hash_keyclasses/package/class.pack.php:3038 - concatenated$hashstraight 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 inesc_html()+ castidto(int). - Privilege escalation in
quick_fixctrls/class.web.services.php:650-672 - an endpoint withCAP_BASICallowed setting ANY property ofGlobal_Entitythrough$_POST['setup']['global'][key]→ aCAP_BASICuser could changepackage_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_running5 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 unfilteredget_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 asetTimeoutchain with progressive backoff: a signature built fromstatus+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.jsonread 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 withwp_cache_setkeyedmd5(path:mtime), namespacesl-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+RESTManagerare 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+getTableEngineListclasses/class.db.php:278-329 -getTableEngineListexecuted the query twice ($wpdb->query()and then$wpdb->get_col($query)).getTableCollationListused a pointless 2-step query+get_col() (the SELECT returnednum_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 withclean-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_runningkey mismatch (release blocker) ctrls/class.web.services.php:1441,1444 - after consolidating the 5 queries, the code pushed intocac_ids/err_idsbut the arrays were initialized ascancel_ids/error_ids→ the JS client received an empty array → lost the status. Fix: renamed back tocancel_ids/error_ids.quick_fixwhitelist typo ctrls/class.web.services.php:653-660 - it saidajax_protocol_custombut the real property iscustom_ajax_url(class.global.entity.php:182) → the "Custom AJAX URL" quick-fix dialog failed silently. Fix: changed tocustom_ajax_url.- OAuth token leaking in plaintext into the trace log src/Utils/OAuth/TokenEntity.php:108 -
wp_json_encode($this)dumped the plaintextaccess_token+refresh_tokenwhen a refresh failed. The trace log is readable byCAP_BASICusers throughwp_ajax_sl_migrations_get_trace_log→ token exposure. Fix: log only thestorage_type+expiredstatus. - Missing CAP_CREATE check for
create_from_tempslm-modern/ModernPackagesController.php:107 + views/packages/main/packages.php:33 - the nonce was fine but there was no capability check → aCAP_BASICuser could create a package from temp data. Fix: added aCapMng::can(CapMng::CAP_CREATE)gate. renameInstallerFileran on every frontend pageview src/Core/Bootstrap.php:75 - theinithook at priority 20 ranExpireOptions::getUpdate(a transient lookup + DB write) on every frontend pageview even when not needed. Fix: wrapped in the$isContextualgate together with ControllersManager/RESTManager.
Files changed
Security:
src/Ajax/FileTransfer/ImportUpload.php- basename path traversalslm-modern/ModernPackagesController.php- CSRF nonce + CAP_CREATEviews/packages/main/packages.php- CSRF nonce + CAP_CREATEviews/packages/main/s2.scan1.base.php- wp_nonce_fieldsrc/Package/Import/PackageImporter.php- no longer logs the archive passwordclasses/entities/class.package.template.entity.php- no longer logs the installer passwordsrc/Utils/Crypt/CryptBlowfish.php- no longer logs plaintext when crypt failssrc/Utils/OAuth/TokenEntity.php- no longer dumps the OAuth token in plaintextclasses/package/class.pack.php-find_hash_keyprepareviews/tools/recovery/widget/recovery-widget-selector.php- esc_html on the package namectrls/class.web.services.php- quick_fix whitelist + custom_ajax_url
Performance:
ctrls/class.web.services.php-is_pack_running1 query + correct response keysassets/js/slm-progress-poller.js- adaptive poll backoffsrc/Compat/slm-progress-api.php- cache scan.jsonsrc/Core/Bootstrap.php- context gate + renameInstallerFilesrc/Utils/Autoloader.php- memoize mappingclasses/class.db.php- removed the duplicate query- 10
assets/css/*.cssfiles minified (27% smaller)
[1.8.1] - 2026-05-19 (Encryption Key Hotfix - Legacy DupPro Cleanup - Footer)
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 existsupdateStorages()- removed entirely (it calledupdateFromGlobal(), which resetmax_packages=20,purge_packages=falseon 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 (renamedsl_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.
Modern UI footer aligned from the left edge
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+usernamemust not be empty - SFTP:
server+usernamemust not be empty, and at least one ofpassword/private_keymust 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_LENGTHon 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 inCOALESCE(..., 0). - Trace log download when
fpassthruis disabled (ctrls/class.web.services.php:1058) - fpassthru is disabled on some shared hosts throughdisable_functions. It now falls back to reading in 8KB chunks if fpassthru does not exist or returnsfalse.
Files changed
assets/js/handlebars.min.js- Handlebars 4.7.8 (security)addons/ftpaddon/src/Models/FTPStorage.php- validate server + usernameaddons/ftpaddon/src/Models/SFTPStorage.php- validate server + username + (password OR private_key)classes/package/class.pack.database.php- COALESCE for NULL table statsctrls/class.web.services.php- chunked-read fallback when fpassthru is disabledsrc/Core/Upgrade/UpgradeFunctions.php- rewritten down to 3 methods (performUpgrade,updateDatabase,storeDupSecureKey)src/Core/MigrationMng.php- callsupdateDatabase()with no parameterssrc/Models/Storages/Local/DefaultLocalStorage.php- deleted the orphanedupdateFromGlobal()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- wrapsRecoveryPackage::setRecoveablePackage()slm_modern_recovery_reset- wrapsSLM_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 → #1d4ed8with 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
disabledwith 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:
ajaxDeleterejects 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).
Fix: Dashboard widget link 404 "Gói không tồn tại" (Package does not exist)
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 " in modals (global UI)
SLM.modal.confirm({body: ...}) displayed a literal " instead of a quote mark because of double escaping: escapeHtml() turned " → ", 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.phpsrc/Models/Storages/AbstractStorageEntity.php- purge skips the Recovery Pointsrc/Package/Import/PackageImporter.php- loweredIMPORT_*_MIN_VERSIONassets/js/slm-ui.js- fixed the modal escape bug
[1.7.4] - 2026-05-18 (Dashboard Widget Link Fix - Modern Recovery Set/Reset)
Fix - Dashboard widget "Recently Packages" link 404
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:
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.
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
- A status dot (set/not set) loaded automatically through
Backend wrappers in ModernPackagesController (a file that already has the slm_modern nonce pattern):
wp_ajax_slm_modern_recovery_status- reads the state, non-destructivewp_ajax_slm_modern_recovery_set- wrapsRecoveryPackage::setRecoveablePackage()wp_ajax_slm_modern_recovery_reset- wrapsSLM_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.
[1.7.3] - 2026-05-18 (Timezone Fix - Sticky Footer - Plugin Row Meta - Landing Page)
Fixes
Timezone shown wrongly on the Packages list: the
Createdcolumn 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 towp_date()(the modern API that respects the WP timezone) + parsing the string with aUTCsuffix 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: fixedfor#wpfooter(inlined into<head>through theadmin_headhook at priority 1) +padding-bottom: 60pxfor#wpbody-content. Dropped the flex approach on#wpwrapbecause 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)
- README →
- 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-migrationson 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>throughadmin_headpriority 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-contentwas forced tomin-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) withflex: 1 0 autofor the content andflex: 0 0 autofor 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
#backtotopof thewp-extraplugin is injected inline withbottom: 15px; once the footer was pinned to the bottom, this button was covered by the footer. Fix: overridebottom: 70px !importantonly within the scopebody[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
.dupfiles: importing a.dupfile from another Duplicator Pro / SL-Migrations site could lose config when the storage is a subclass (SFTP, S3, ...) becauseReflectionClass(self::class)only picks up the properties ofAbstractStorageEntityand misses the subclass properties. Fix: usestatic::classso late static binding picks the right runtime class. - Import Settings - robustness:
MigrateSettings::importStorages/Templates/Schedules/Settingshad no try/catch, so one malformed entity brought down the whole import. Fix: wrap each item in try/catch + validateis_array($data)+ checkisset($data['id'])before mapping the id. AbstractStorageEntity::__wakeup(): an implicitisset(null[$key])whenCryptBlowfish::decrypt()fails - silently skipped, with the config going back to default and no log. Fix: an explicitis_array($config)guard + a fallback of[].AbstractStorageEntity::settingsImport(): validate that$datais an array, forceconfigto always be an array (if the source export has the wrong structure), and wrapsetValue()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.dupfiles 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
.dupfiles (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 theSFTPStoragesubclass.
[1.7.1] - 2026-05-17 (CSS Fixes - Template Edit Page)
Fixes
- Template Edit - Filters section:
.section-title { height: 20px; padding: 10px }with WP adminbox-sizing: border-boxproduced a content area of 0px → the "Enable" checkbox was clipped and the filter links overlapped. Fix: overrideheight: auto; min-height: 44px; align-items: center. - Template Edit - Installer → Branding row: the
<select>hadmin-height: 40pxwhile the "Preview" button hadmin-height: 38px→ the heights did not match. Fix: synced both toheight: 38px. - Filter textareas: the global
textarea { min-height: 100px }inslm-overrides.cssmade#filter-pathsand#filter-extstoo tall and caused layout overflow. Fix: a dedicated scope withmin-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.phptrimmed 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:icorrectly - 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_migrationsand#dup-schedule-formare forced todisplay: 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) aredisplay: noneso 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 thegetCreated()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 ontoplevel_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-primaryand.button-secondaryall 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:00is 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 Lamin 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 changedsnapcreek.com→phatdev.netacross the installer and library files
Technical
SLM_ASSETS_VERbumped to1.6.0to 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'inControllersManager
[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 namespaceVendorSLMigrations\ - Constants
SL_MIGRATIONS_*/SLM_PRO_*; hookssl_migrations_*; text domainsl-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)