---
title: "Changelog"
url: https://phatdev.net/en/docs/sl-migrations/changelog
lang: en
updated: 2026-10-20
alternate:
  vi: https://phatdev.net/docs/sl-migrations/changelog
markdown: https://phatdev.net/en/docs/sl-migrations/changelog.md
---

# Changelog

Part of the doc set: [SL Migrations](https://phatdev.net/en/docs/sl-migrations.md)

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

## CHANGELOG - SL Migrations

All notable changes to the **SL Migrations** plugin are recorded here.
Follows the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) 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](src/Ajax/FileTransfer/ImportUpload.php#L117-L126) - `$_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](slm-modern/ModernPackagesController.php#L96-L159) + [views/packages/main/packages.php:25-58](views/packages/main/packages.php#L25-L58) - `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](src/Package/Import/PackageImporter.php#L309) - archive password → now logs `(length=N)` instead of the raw value
  - [classes/entities/class.package.template.entity.php:251](classes/entities/class.package.template.entity.php#L251) - installer password → same
  - [src/Utils/Crypt/CryptBlowfish.php:189,243](src/Utils/Crypt/CryptBlowfish.php#L189) - 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](classes/package/class.pack.php#L3038) - 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](views/tools/recovery/widget/recovery-widget-selector.php#L91-L93) - `$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](ctrls/class.web.services.php#L650-L672) - 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](ctrls/class.web.services.php#L1420-L1490) - 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](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](src/Compat/slm-progress-api.php#L165-L200) - 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](src/Core/Bootstrap.php#L80-L93) - `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](src/Utils/Autoloader.php#L139-L192) - `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](classes/class.db.php#L278-L329) - `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](ctrls/class.web.services.php#L1441) - 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](ctrls/class.web.services.php#L653) - it said `ajax_protocol_custom` but the real property is `custom_ajax_url` ([class.global.entity.php:182](classes/entities/class.global.entity.php#L182)) → 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](src/Utils/OAuth/TokenEntity.php#L108) - `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](slm-modern/ModernPackagesController.php#L107) + [views/packages/main/packages.php:33](views/packages/main/packages.php#L33) - 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](src/Core/Bootstrap.php#L75) - 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)

---

## [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](src/Core/Upgrade/UpgradeFunctions.php#L258-L265) - 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:

```php
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](src/Models/Storages/AbstractStorageEntity.php#L134-L156) fell back to `getDefaultConfig()`:

- **Default storage** ([Local/DefaultLocalStorage.php:49-50](src/Models/Storages/Local/DefaultLocalStorage.php#L49-L50)): `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](addons/ftpaddon/src/Models/SFTPStorage.php#L37-L54)): `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](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()](src/Models/Storages/Local/DefaultLocalStorage.php) - 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](https://github.com/handlebars-lang/handlebars.js/releases/tag/v4.7.8) (the last security patch of the 4.x line). The `registerHelper` + `compile` API in use at [assets/js/javascript.php](assets/js/javascript.php), [views/packages/main/s2.scan3.archive.php](views/packages/main/s2.scan3.archive.php) and [views/tools/diagnostics/inc.validator.php](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](https://snapcreek.com/duplicator/changelog)).

Server-side validation in [FTPStorage::updateFromHttpRequest](addons/ftpaddon/src/Models/FTPStorage.php) and [SFTPStorage::updateFromHttpRequest](addons/ftpaddon/src/Models/SFTPStorage.php):

- 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](classes/package/class.pack.database.php#L229)) - `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](ctrls/class.web.services.php#L1058)) - 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`).

### 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 `&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

---

## [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:**

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.

---

## [1.7.3] - 2026-05-18 (Timezone Fix - Sticky Footer - Plugin Row Meta - Landing Page)

### 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](slm-modern/ModernPackagesController.php))
- **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](src/Core/Bootstrap.php#L659)): 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](../../phatdev.net/app/scripts/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)
