Prepare the project for production deployment and final client
acceptance testing. Adds preflight fail-fast command, hardened FX rate
importer, currency seeder, deploy scripts, E2E smoke test, and the
43-item UAT checklist document with sign-off form.
Key changes:
- Phase 3.9.1 — hotfix (AddressBookEntry):
- Add $attributes defaults (use_count=0, is_default=false) so
instance values are populated right after create() without
refresh(). Resolves the red test
`it_increments_use_count_and_sets_last_used_at`.
- Phase 3.10 — deploy infrastructure (Laravel):
- Add `ifnex:preflight` command: fail-fast audit of production
environment (APP_ENV, APP_DEBUG, APP_KEY, IFNEX_API_KEY,
IFNEX_BRIDGE_API_KEY, ZARINPAL_SANDBOX, DB_CONNECTION, CORS,
currencies seeded, PHP extensions, storage writable). Exits 1
if any check fails; supports --skip-db for pre-migrate runs.
- Add `ifnex:import-fx-rates` command: replacement for
`ifnex:update-rates`. Fetches USD/AED from Exir.io + EUR/CNY
from FreeCurrencyAPI. Supports --auto-approve (cron use) and
--threshold (max change % for auto-approve). Logs critical on
HTTP failure. URL configurable via EXIR_API_URL env.
- Add `CurrencySeeder`: creates 5 base currencies (USD/AED/EUR/
CNY/IRR) with rate=0 (IRR=1). Idempotent — does not overwrite
real rates already set by admin or cron.
- Update `DatabaseSeeder`: skip super_admin with password
'password' in production (still created in local/testing).
- Fix `bootstrap/app.php`: remove dead `use ApiKeyAuth` import
(actual class is ApiKeyMiddleware). Latent fatal if api_key
alias was used.
- Add `config/ifnex.php` exir block + preflight config.
- Harden `.env.example`: MySQL default, EXIR_API_URL,
SANCTUM_STATEFUL_DOMAINS, APP_TIMEZONE, LOG_DAILY_DAYS, fix
duplicate CURRENCY_API_KEY.
- Phase 3.10 — deploy scripts (Linux):
- Add `scripts/deploy.sh`: idempotent 13-step deploy (composer
install → key:generate → preflight → migrate → seed → FX import
→ cache → chown). Fail-fast with set -e.
- Add `scripts/cron-exir-rates.sh`: daily cron wrapper for
`ifnex:import-fx-rates --source=all --auto-approve`.
- Phase 3.10.1 — preflight hotfix:
- Make expected DB connection configurable
(ifnex.preflight.expected_db_connection) instead of hardcoded
'mysql'. Prevents cascade test failure when tests would have to
mutate database.default to mysql in a sqlite test environment
(Unknown database ':memory:' → broken connection pool → 22
previously-green tests fail with "There is already an active
transaction").
- Rewrite IfnexPreflightTest to use config() rather than mutating
database.default.
- Phase 3.11 — UAT:
- Add `ifnex:smoke-test` command: E2E post-deploy automated test
(25+ checks: DB connection, core tables, currencies with
non-zero rates, countries count, roles, admin user, system
settings, storage writable, config/route cache, 4 API
endpoints: /up, /countries, /track/{invalid}, /customer/profile
without token).
- Add 9 feature tests for smoke-test (PASS + 6 FAIL + custom URL
+ 422/500 handling + auth bypass detection).
- Add `UAT-3.11.md`: 43-item Persian checklist for client
acceptance testing across 15 feature areas. Includes sign-off
form for both parties (client + VernaSoft).
- Update `CLIENT_DELIVERY.md` to record phases 3.10 and 3.11.
Tests:
- Total: 51 → 82 tests, 150 → 220+ assertions, all green.
- New: IfnexPreflightTest (9), IfnexImportFxRatesTest (9),
CurrencySeederTest (4), IfnexSmokeTestTest (9) = 31 new tests.
Documentation:
- DEPLOYMENT_HANDOFF.md §2.4: add CurrencySeeder + import-fx-rates.
- DEPLOYMENT_HANDOFF.md §2.8: update cron to use cron-exir-rates.sh.
- UAT-3.11.md: new 43-item client acceptance checklist + sign-off.
- CLIENT_DELIVERY.md: record phases 3.10 and 3.11 completion.
236 lines
7.1 KiB
PHP
236 lines
7.1 KiB
PHP
<?php
|
|
/**
|
|
* Customize API: WP_Customize_Header_Image_Setting class
|
|
*
|
|
* @package WordPress
|
|
* @subpackage Customize
|
|
* @since 4.4.0
|
|
*/
|
|
|
|
/**
|
|
* A setting that is used to filter a value, but will not save the results.
|
|
*
|
|
* Results should be properly handled using another setting or callback.
|
|
*
|
|
* @since 3.4.0
|
|
*
|
|
* @see WP_Customize_Setting
|
|
*
|
|
* @phpstan-type Header_Image_Data array{
|
|
* attachment_id?: int,
|
|
* url?: string,
|
|
* thumbnail_url?: string,
|
|
* timestamp?: int,
|
|
* width?: int,
|
|
* height?: int,
|
|
* alt_text?: string,
|
|
* attachment_parent?: int,
|
|
* }
|
|
*/
|
|
final class WP_Customize_Header_Image_Setting extends WP_Customize_Setting {
|
|
|
|
/**
|
|
* Unique string identifier for the setting.
|
|
*
|
|
* @since 3.4.0
|
|
* @var string
|
|
*/
|
|
public $id = 'header_image_data';
|
|
|
|
/**
|
|
* @since 3.4.0
|
|
* @since 7.0.0 Return type updated from void to true for compatibility with base class.
|
|
*
|
|
* @global Custom_Image_Header $custom_image_header
|
|
*
|
|
* @param mixed $value The value to update.
|
|
* @return true Always returns true.
|
|
*/
|
|
public function update( $value ) {
|
|
global $custom_image_header;
|
|
|
|
// If _custom_header_background_just_in_time() fails to initialize $custom_image_header when not is_admin().
|
|
if ( empty( $custom_image_header ) ) {
|
|
require_once ABSPATH . 'wp-admin/includes/class-custom-image-header.php';
|
|
$args = get_theme_support( 'custom-header' );
|
|
$admin_head_callback = $args[0]['admin-head-callback'] ?? null;
|
|
$admin_preview_callback = $args[0]['admin-preview-callback'] ?? null;
|
|
$custom_image_header = new Custom_Image_Header( $admin_head_callback, $admin_preview_callback );
|
|
}
|
|
|
|
/*
|
|
* If the value doesn't exist (removed or random),
|
|
* use the header_image value.
|
|
*/
|
|
if ( ! $value ) {
|
|
$value = $this->manager->get_setting( 'header_image' )->post_value();
|
|
}
|
|
|
|
if ( is_array( $value ) && isset( $value['choice'] ) ) {
|
|
$custom_image_header->set_header_image( $value['choice'] );
|
|
} else {
|
|
$custom_image_header->set_header_image( $value );
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a header value.
|
|
*
|
|
* The value is expected to be one of the following:
|
|
*
|
|
* - An array of header image data, with the keys `attachment_id`, `url`, `thumbnail_url`, `timestamp`, `width`,
|
|
* `height`, `alt_text`, and `attachment_parent`, as supplied by {@see get_uploaded_header_images()}. Any other
|
|
* key is discarded.
|
|
* - An array with a `choice` key, being the legacy format in which any of the other accepted values is nested.
|
|
* - The string `remove-header`, `random-default-image`, or `random-uploaded-image`.
|
|
* - A string corresponding to one of the keys for the array returned by {@see get_uploaded_header_images()}, or
|
|
* one of the keys for the array passed into {@see register_default_headers()}.
|
|
*
|
|
* @since 7.1.1
|
|
*
|
|
* @see WP_Customize_Header_Image_Setting::update()
|
|
* @see Custom_Image_Header::set_header_image()
|
|
*
|
|
* @param mixed $value Value to sanitize.
|
|
* @return array|string|WP_Error|null Sanitized value, or `null`/`WP_Error` if invalid. The array holds
|
|
* the header image data, or that data nested under a `choice` key,
|
|
* before the `customize_sanitize_header_image_data` filter, which
|
|
* may return anything, is applied to it.
|
|
*
|
|
* @phpstan-return array<mixed, mixed>|string|WP_Error|null
|
|
*/
|
|
public function sanitize( $value ) {
|
|
/*
|
|
* The update() method unwraps the legacy `choice` format before handing the value off to
|
|
* Custom_Image_Header::set_header_image(), so the nested value is what must be sanitized.
|
|
*/
|
|
if ( is_array( $value ) && isset( $value['choice'] ) ) {
|
|
$choice = $this->sanitize_choice( $value['choice'] );
|
|
if ( is_null( $choice ) || is_wp_error( $choice ) ) {
|
|
return $choice;
|
|
}
|
|
$value = array( 'choice' => $choice );
|
|
} else {
|
|
$value = $this->sanitize_choice( $value );
|
|
if ( is_null( $value ) || is_wp_error( $value ) ) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return parent::sanitize( $value );
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a header image choice.
|
|
*
|
|
* This is the value which is ultimately passed to {@see Custom_Image_Header::set_header_image()}, whether
|
|
* supplied at the top level of the setting value or nested under its legacy `choice` key.
|
|
*
|
|
* @since 7.1.1
|
|
*
|
|
* @param mixed $value Value to sanitize.
|
|
* @return array|string|WP_Error|null Sanitized value, or `null`/`WP_Error` if invalid.
|
|
*
|
|
* @phpstan-return Header_Image_Data|string|WP_Error|null
|
|
*/
|
|
private function sanitize_choice( $value ) {
|
|
// Custom_Image_Header::set_header_image() accepts an object in place of an array.
|
|
if ( is_object( $value ) ) {
|
|
$value = (array) $value;
|
|
}
|
|
|
|
if ( is_string( $value ) ) {
|
|
return sanitize_text_field( $value );
|
|
}
|
|
|
|
if ( ! is_array( $value ) ) {
|
|
return null;
|
|
}
|
|
|
|
/*
|
|
* The sanitized value is assembled member by member rather than filtered down from the
|
|
* supplied one, so that nothing but the members below can end up in it.
|
|
*/
|
|
$sanitized = array();
|
|
|
|
if ( isset( $value['attachment_id'] ) ) {
|
|
if ( ! is_scalar( $value['attachment_id'] ) ) {
|
|
return null;
|
|
}
|
|
$attachment_id = absint( $value['attachment_id'] );
|
|
|
|
/*
|
|
* A supplied attachment must be an existing image, since its ID is written to postmeta and its
|
|
* data displayed. Note that an ID of zero must be skipped rather than looked up, as
|
|
* get_post_mime_type() falls back to the global post when passed an empty value.
|
|
*/
|
|
if ( $attachment_id > 0 ) {
|
|
$mime_type = get_post_mime_type( $attachment_id );
|
|
if ( ! is_string( $mime_type ) || ! str_starts_with( $mime_type, 'image/' ) ) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
$sanitized['attachment_id'] = $attachment_id;
|
|
}
|
|
|
|
if ( isset( $value['url'] ) ) {
|
|
if ( ! is_string( $value['url'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['url'] = sanitize_url( $value['url'] );
|
|
if ( '' === $sanitized['url'] ) {
|
|
return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) );
|
|
}
|
|
}
|
|
|
|
if ( isset( $value['thumbnail_url'] ) ) {
|
|
if ( ! is_string( $value['thumbnail_url'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['thumbnail_url'] = sanitize_url( $value['thumbnail_url'] );
|
|
if ( '' === $sanitized['thumbnail_url'] ) {
|
|
return new WP_Error( 'invalid_url', __( 'Invalid URL.' ) );
|
|
}
|
|
}
|
|
|
|
if ( isset( $value['timestamp'] ) ) {
|
|
if ( ! is_scalar( $value['timestamp'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['timestamp'] = absint( $value['timestamp'] );
|
|
}
|
|
|
|
if ( isset( $value['width'] ) ) {
|
|
if ( ! is_scalar( $value['width'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['width'] = absint( $value['width'] );
|
|
}
|
|
|
|
if ( isset( $value['height'] ) ) {
|
|
if ( ! is_scalar( $value['height'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['height'] = absint( $value['height'] );
|
|
}
|
|
|
|
if ( isset( $value['alt_text'] ) ) {
|
|
if ( ! is_string( $value['alt_text'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['alt_text'] = sanitize_text_field( $value['alt_text'] );
|
|
}
|
|
|
|
if ( isset( $value['attachment_parent'] ) ) {
|
|
if ( ! is_scalar( $value['attachment_parent'] ) ) {
|
|
return null;
|
|
}
|
|
$sanitized['attachment_parent'] = absint( $value['attachment_parent'] );
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
}
|