| 1 |
<?php |
| 2 |
/** |
| 3 |
* Import context — the validated form of `site_obj`. |
| 4 |
* |
| 5 |
* In the wizard, `site_obj` is accumulated across four screens (import_temp_preview → |
| 6 |
* import_temp_feature → import_temp_method → import_content_media) and every value is |
| 7 |
* something the site's own admin typed or picked. A PHP runner has no such guarantee: its |
| 8 |
* context may eventually arrive from somewhere else entirely, so this class is the single |
| 9 |
* place where a context is checked before anything acts on it. |
| 10 |
* |
| 11 |
* The rules here are deliberately strict and deliberately boring: |
| 12 |
* |
| 13 |
* - Enumerated fields (language, industry, import type, builder) must match a known value |
| 14 |
* or fall back to the default. Never passed through. |
| 15 |
* - `plugin_setting` is **dropped**, and `theme_setting` is accepted only as a BOOLEAN. |
| 16 |
* Which plugins an import installs is derived from the kit's own dependency list, never |
| 17 |
* from the context - otherwise a context becomes a way to install arbitrary plugins. |
| 18 |
* Dropping the theme flag outright went too far: the wizard's "Nexter Theme" toggle stopped |
| 19 |
* doing anything, so an import that used to switch the site's theme silently did not, and |
| 20 |
* the theme never appeared in the progress list either. A boolean cannot name a package, so |
| 21 |
* the property that matters is kept - the theme itself is a fixed descriptor defined in the |
| 22 |
* runner, not something the caller supplies. |
| 23 |
* - `reset_site` defaults to **false**. The wizard's own default is destructive (it drafts |
| 24 |
* every published page); a non-interactive runner must never inherit that silently. |
| 25 |
* - Image URLs are SSRF-validated with the plugin's existing resolver. |
| 26 |
* |
| 27 |
* Nothing here is wired to the website. It is the contract a Phase 2 payload will have to |
| 28 |
* satisfy, established now so the runner can be written against it. |
| 29 |
* |
| 30 |
* @package Wdesignkit |
| 31 |
* @since 2.6.5 |
| 32 |
*/ |
| 33 |
|
| 34 |
if ( ! defined( 'ABSPATH' ) ) { |
| 35 |
exit; |
| 36 |
} |
| 37 |
|
| 38 |
if ( ! class_exists( 'Wdkit_Import_Context' ) ) { |
| 39 |
|
| 40 |
/** |
| 41 |
* Validated import context. |
| 42 |
*/ |
| 43 |
class Wdkit_Import_Context { |
| 44 |
|
| 45 |
/** |
| 46 |
* Languages the AI prompt accepts. Mirrors `lang_list` in import_content_media.js. |
| 47 |
* |
| 48 |
* @var string[] |
| 49 |
*/ |
| 50 |
private static $languages = array( |
| 51 |
'arabic', 'bengali', 'burmese', 'chinese', 'dutch', 'english', 'filipino', |
| 52 |
'french', 'german', 'greek', 'gujarati', 'hebrew', 'hindi', 'indonesian', |
| 53 |
'italian', 'japanese', 'kannada', 'korean', 'malay', 'marathi', 'nepali', |
| 54 |
'persian', 'polish', 'portuguese', 'punjabi', 'romanian', 'russian', 'spanish', |
| 55 |
'swedish', 'tamil', 'telugu', 'thai', 'turkish', 'urdu', 'vietnamese', |
| 56 |
); |
| 57 |
|
| 58 |
/** |
| 59 |
* Team-photo collection ids. Mirrors `agency_list` in import_content_media.js. |
| 60 |
* |
| 61 |
* @var string[] |
| 62 |
*/ |
| 63 |
private static $industries = array( '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15' ); |
| 64 |
|
| 65 |
/** |
| 66 |
* Import modes the wizard offers. |
| 67 |
* |
| 68 |
* @var string[] |
| 69 |
*/ |
| 70 |
private static $import_types = array( 'normal_import', 'ai_import' ); |
| 71 |
|
| 72 |
/** |
| 73 |
* Builders a kit can be authored in. |
| 74 |
* |
| 75 |
* @var string[] |
| 76 |
*/ |
| 77 |
private static $builders = array( 'elementor', 'gutenberg' ); |
| 78 |
|
| 79 |
/** |
| 80 |
* Keys accepted on `site_info`. Anything else is discarded. |
| 81 |
* |
| 82 |
* @var string[] |
| 83 |
*/ |
| 84 |
private static $site_info_keys = array( 'site_name', 'tagline', 'email', 'phone', 'address', 'logo', 'social_links' ); |
| 85 |
|
| 86 |
/** |
| 87 |
* Social networks the template's `social_links` may carry. Mirrors `social_list` |
| 88 |
* in import_temp_preview.js. |
| 89 |
* |
| 90 |
* @var string[] |
| 91 |
*/ |
| 92 |
private static $social_keys = array( 'facebook', 'twitter', 'instagram', 'linkedin', 'youtube' ); |
| 93 |
|
| 94 |
/** |
| 95 |
* Build a validated context from a loose array. |
| 96 |
* |
| 97 |
* Always returns a complete, usable context — invalid values become defaults rather |
| 98 |
* than errors, so a runner never has to reason about half-populated input. |
| 99 |
* |
| 100 |
* @param array $args Raw context. |
| 101 |
* @return array |
| 102 |
*/ |
| 103 |
public static function normalize( $args ) { |
| 104 |
$args = is_array( $args ) ? $args : array(); |
| 105 |
|
| 106 |
$context = array( |
| 107 |
'kit_id' => self::text( self::pick( $args, 'kit_id' ), 64 ), |
| 108 |
'builder' => self::enum( self::pick( $args, 'builder' ), self::$builders, 'elementor' ), |
| 109 |
|
| 110 |
/* Did the caller actually name a builder, or is the line above just reporting the |
| 111 |
* default? enum() cannot tell those apart afterwards, and the difference decides |
| 112 |
* whether a builder/content mismatch is a real error or a missing hint. */ |
| 113 |
'builder_explicit' => in_array( self::pick( $args, 'builder' ), self::$builders, true ), |
| 114 |
'import_type' => self::enum( self::pick( $args, 'import_type' ), self::$import_types, 'normal_import' ), |
| 115 |
|
| 116 |
/** AI copy fields — these feed the prompt, so length is capped, markup stripped */ |
| 117 |
'site_type' => self::text( self::pick( $args, 'site_type' ), 160 ), |
| 118 |
'site_description' => self::description( self::pick( $args, 'site_description' ) ), |
| 119 |
'site_lang' => self::enum( self::lower( self::pick( $args, 'site_lang' ) ), self::$languages, 'english' ), |
| 120 |
'site_agency' => self::enum( self::text( self::pick( $args, 'site_agency' ), 2 ), self::$industries, '' ), |
| 121 |
'site_category_id' => self::text( self::pick( $args, 'site_category_id' ), 12 ), |
| 122 |
|
| 123 |
'site_info' => self::site_info( self::pick( $args, 'site_info' ) ), |
| 124 |
'images' => self::images( self::pick( $args, 'images' ) ), |
| 125 |
'templates' => self::templates( self::pick( $args, 'templates' ) ), |
| 126 |
|
| 127 |
/* The catalogue kit `plugins_id` values resolve against — the same list the |
| 128 |
* browser receives as `wdkit_meta.plugin`. Validated to the fields the installer |
| 129 |
* reads, and note what this does NOT enable: a caller cannot install anything |
| 130 |
* by adding an entry here, because a dependency only exists if some template's |
| 131 |
* `plugins_id` names its id. The catalogue can narrow, never widen. */ |
| 132 |
'plugin_catalogue' => self::plugin_catalogue( self::pick( $args, 'plugin_catalogue' ) ), |
| 133 |
|
| 134 |
/* Site-level globals (container width, body background, __globals__). Shape is |
| 135 |
* re-checked by Wdkit_Import_Globals before anything is written. */ |
| 136 |
'site_global' => is_array( self::pick( $args, 'site_global' ) ) ? self::pick( $args, 'site_global' ) : array(), |
| 137 |
|
| 138 |
/* The KIT's own global palette and typography, as the wizard carries it |
| 139 |
* (site_obj.kit_global = {color, typo}). Distinct from `site_global` above, |
| 140 |
* which is site-level layout values. Shape is re-checked by |
| 141 |
* Wdkit_Import_Globals::from_kit() before anything is written. */ |
| 142 |
'kit_global' => is_array( self::pick( $args, 'kit_global' ) ) ? self::pick( $args, 'kit_global' ) : array(), |
| 143 |
|
| 144 |
/* Font swaps chosen on the fonts screen: [{original, replace}]. */ |
| 145 |
'font_family' => is_array( self::pick( $args, 'font_family' ) ) ? self::pick( $args, 'font_family' ) : array(), |
| 146 |
|
| 147 |
/** booleans the wizard sets on the method screen */ |
| 148 |
'wirefram_import' => self::bool( self::pick( $args, 'wirefram_import' ), false ), |
| 149 |
'blog_post' => self::bool( self::pick( $args, 'blog_post' ), true ), |
| 150 |
/* Whether imported blog posts get AI copy. Posts are imported either way. */ |
| 151 |
'ai_blog' => self::bool( self::pick( $args, 'ai_blog' ), true ), |
| 152 |
|
| 153 |
/* Destructive, and off unless something explicitly asks for it. The wizard |
| 154 |
* keeps its own default; this one is for non-interactive runs. */ |
| 155 |
/* Whether to install and activate the Nexter theme. A flag, never a slug. */ |
| 156 |
'theme_setting' => self::bool( self::pick( $args, 'theme_setting' ), false ), |
| 157 |
|
| 158 |
/* Whether this import wants a shop. A flag, for the same reason theme_setting is |
| 159 |
* one: `plugin_setting` itself is dropped by this normaliser (see the class note), |
| 160 |
* so the wizard reduces its ecommerce entry to this boolean before handing it |
| 161 |
* over. Wdkit_Import_Products reads it to decide whether an empty catalogue means |
| 162 |
* "this kit ships no products" or "fall back to the demo six" — a distinction |
| 163 |
* WooCommerce merely being active cannot make. */ |
| 164 |
'ecommerce' => self::bool( self::pick( $args, 'ecommerce' ), false ), |
| 165 |
|
| 166 |
/* The rest of the wizard's feature switches, as NAMES from a fixed list - never |
| 167 |
* plugin slugs. `plugin_setting` (which does carry slugs) is dropped by this |
| 168 |
* normaliser on purpose, so the wizard reduces its switches to these names and |
| 169 |
* the runner maps each one to a descriptor it holds itself. A caller can ask for |
| 170 |
* "security"; it cannot name a package to install. |
| 171 |
* |
| 172 |
* Without this the switches did nothing at all on the PHP-runner path: the |
| 173 |
* runner installed the kit's own dependencies and the theme, and every plugin |
| 174 |
* the visitor had asked for with a switch was silently skipped |
| 175 |
* (ClickUp 14ynqxyykan). */ |
| 176 |
'features' => self::features( self::pick( $args, 'features' ) ), |
| 177 |
|
| 178 |
'reset_site' => self::bool( self::pick( $args, 'reset_site' ), false ), |
| 179 |
|
| 180 |
/* Disabling the site's existing header/footer/etc. templates — see |
| 181 |
* Wdkit_Import_Reset::run(). Not destructive to page content, so it defaults to |
| 182 |
* whatever reset_site resolved to UNLESS a caller named it explicitly (a bare |
| 183 |
* bool default here cannot express "mirror the other field", so the array_key |
| 184 |
* check happens in Reset::run() itself, against this raw args array — the |
| 185 |
* normalized context still needs the key to exist and carry the caller's |
| 186 |
* explicit choice through untouched when they made one). */ |
| 187 |
'reset_builders' => array_key_exists( 'reset_builders', $args ) |
| 188 |
? self::bool( self::pick( $args, 'reset_builders' ), false ) |
| 189 |
: null, |
| 190 |
|
| 191 |
/* Gates every permanent deletion in Wdkit_Import_Cleanup — the sample-post |
| 192 |
* removal and the retry-orphan sweep. Default false, and nothing flips it |
| 193 |
* implicitly. Even when true, cleanup still refuses ids the session cannot |
| 194 |
* prove this run created. */ |
| 195 |
'allow_destructive_cleanup' => self::bool( self::pick( $args, 'allow_destructive_cleanup' ), false ), |
| 196 |
|
| 197 |
/* Products the kit ships, if any. Validated per-record by |
| 198 |
* Wdkit_Import_Products::normalize() rather than here, because the shape is |
| 199 |
* the generator's, not ours. */ |
| 200 |
'products' => is_array( self::pick( $args, 'products' ) ) ? self::pick( $args, 'products' ) : array(), |
| 201 |
|
| 202 |
/* Pre-generated AI content for the whole site, normalised through the canonical |
| 203 |
* document contract. This is the field a future remote caller fills so the |
| 204 |
* import can run without a browser performing the merge. |
| 205 |
* |
| 206 |
* Normalising here rather than at the point of use is the security boundary: |
| 207 |
* whatever arrives, what reaches the importer is a closed set of page element |
| 208 |
* replacements plus whitelisted product and post fields. Unknown keys are gone |
| 209 |
* by the time anything reads this. */ |
| 210 |
'ai_document' => self::ai_document( self::pick( $args, 'ai_document' ), self::enum( self::pick( $args, 'builder' ), self::$builders, 'elementor' ) ), |
| 211 |
|
| 212 |
/* Whether a product this importer previously created may have its copy |
| 213 |
* refreshed. The browser never updates — it only creates — so this is off by |
| 214 |
* default, and even on it can only touch products carrying our own marker. */ |
| 215 |
'update_existing_products' => self::bool( self::pick( $args, 'update_existing_products' ), false ), |
| 216 |
); |
| 217 |
|
| 218 |
return $context; |
| 219 |
} |
| 220 |
|
| 221 |
/** |
| 222 |
* Normalise a supplied AI document. |
| 223 |
* |
| 224 |
* Delegates to the contract rather than re-validating here, so there is one definition |
| 225 |
* of what a payload may contain. |
| 226 |
* |
| 227 |
* @param mixed $value Raw document. |
| 228 |
* @param string $builder Builder. |
| 229 |
* @return array |
| 230 |
*/ |
| 231 |
private static function ai_document( $value, $builder ) { |
| 232 |
if ( null === $value || '' === $value ) { |
| 233 |
return array(); |
| 234 |
} |
| 235 |
|
| 236 |
if ( ! class_exists( 'Wdkit_Ai_Content' ) ) { |
| 237 |
return array(); |
| 238 |
} |
| 239 |
|
| 240 |
return Wdkit_Ai_Content::normalize_document( $value, $builder ); |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Fetch a key, tolerating both canonical and wizard-style names. |
| 245 |
* |
| 246 |
* @param array $args Raw context. |
| 247 |
* @param string $key Canonical key. |
| 248 |
* @return mixed|null |
| 249 |
*/ |
| 250 |
private static function pick( $args, $key ) { |
| 251 |
if ( array_key_exists( $key, $args ) ) { |
| 252 |
return $args[ $key ]; |
| 253 |
} |
| 254 |
|
| 255 |
/** the wizard's own aliases, so an existing site_obj can be passed straight in */ |
| 256 |
$aliases = array( |
| 257 |
'site_description' => array( 'site_desc', 'description' ), |
| 258 |
'site_lang' => array( 'language' ), |
| 259 |
'site_agency' => array( 'agency', 'industry' ), |
| 260 |
'site_type' => array( 'type' ), |
| 261 |
'builder' => array( 'kit_builder', 'editor' ), |
| 262 |
); |
| 263 |
|
| 264 |
if ( isset( $aliases[ $key ] ) ) { |
| 265 |
foreach ( $aliases[ $key ] as $alias ) { |
| 266 |
if ( array_key_exists( $alias, $args ) ) { |
| 267 |
return $args[ $alias ]; |
| 268 |
} |
| 269 |
} |
| 270 |
} |
| 271 |
|
| 272 |
return null; |
| 273 |
} |
| 274 |
|
| 275 |
/** |
| 276 |
* An absolute URL, or '' when the value is not one. |
| 277 |
* |
| 278 |
* @param mixed $value Raw URL. |
| 279 |
* @return string |
| 280 |
*/ |
| 281 |
private static function url( $value ) { |
| 282 |
if ( ! is_string( $value ) || '' === $value ) { |
| 283 |
return ''; |
| 284 |
} |
| 285 |
|
| 286 |
$url = esc_url_raw( trim( $value ) ); |
| 287 |
|
| 288 |
return ( strlen( $url ) > 2048 ) ? '' : $url; |
| 289 |
} |
| 290 |
|
| 291 |
/** |
| 292 |
* Trim and length-cap a scalar. |
| 293 |
* |
| 294 |
* @param mixed $value Raw. |
| 295 |
* @param int $limit Maximum length. |
| 296 |
* @return string |
| 297 |
*/ |
| 298 |
private static function text( $value, $limit ) { |
| 299 |
if ( is_array( $value ) || is_object( $value ) || is_null( $value ) ) { |
| 300 |
return ''; |
| 301 |
} |
| 302 |
|
| 303 |
$value = sanitize_text_field( (string) $value ); |
| 304 |
|
| 305 |
return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $limit ) : substr( $value, 0, $limit ); |
| 306 |
} |
| 307 |
|
| 308 |
/** |
| 309 |
* The description can be an array in the wizard (variants, with an active index), so |
| 310 |
* the first non-empty entry is taken when that shape arrives. |
| 311 |
* |
| 312 |
* @param mixed $value Value. |
| 313 |
* @return string |
| 314 |
*/ |
| 315 |
private static function description( $value ) { |
| 316 |
if ( is_array( $value ) ) { |
| 317 |
foreach ( $value as $entry ) { |
| 318 |
if ( is_string( $entry ) && '' !== trim( $entry ) ) { |
| 319 |
$value = $entry; |
| 320 |
break; |
| 321 |
} |
| 322 |
} |
| 323 |
} |
| 324 |
|
| 325 |
if ( ! is_string( $value ) ) { |
| 326 |
return ''; |
| 327 |
} |
| 328 |
|
| 329 |
$value = sanitize_textarea_field( $value ); |
| 330 |
|
| 331 |
return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, 1500 ) : substr( $value, 0, 1500 ); |
| 332 |
} |
| 333 |
|
| 334 |
/** |
| 335 |
* @param mixed $value Value. |
| 336 |
* @return string |
| 337 |
*/ |
| 338 |
private static function lower( $value ) { |
| 339 |
return is_string( $value ) ? strtolower( trim( $value ) ) : ''; |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* @param mixed $value Value. |
| 344 |
* @param string[] $allowed Allowed values. |
| 345 |
* @param string $default Fallback. |
| 346 |
* @return string |
| 347 |
*/ |
| 348 |
/** |
| 349 |
* The wizard's feature switches, reduced to a known-name list. |
| 350 |
* |
| 351 |
* @since 2.7.3 |
| 352 |
* |
| 353 |
* @param mixed $value Raw value from the payload. |
| 354 |
* @return string[] Names from self::FEATURES only, de-duplicated. |
| 355 |
*/ |
| 356 |
private static function features( $value ) { |
| 357 |
if ( ! is_array( $value ) ) { |
| 358 |
return array(); |
| 359 |
} |
| 360 |
|
| 361 |
$known = array( 'ecommerce', 'dynamic_content', 'performance', 'security', 'extras' ); |
| 362 |
$out = array(); |
| 363 |
|
| 364 |
foreach ( $value as $name ) { |
| 365 |
$name = is_string( $name ) ? strtolower( trim( $name ) ) : ''; |
| 366 |
|
| 367 |
if ( '' !== $name && in_array( $name, $known, true ) && ! in_array( $name, $out, true ) ) { |
| 368 |
$out[] = $name; |
| 369 |
} |
| 370 |
} |
| 371 |
|
| 372 |
return $out; |
| 373 |
} |
| 374 |
|
| 375 |
private static function enum( $value, $allowed, $default ) { |
| 376 |
$value = is_string( $value ) ? trim( $value ) : ''; |
| 377 |
|
| 378 |
return in_array( $value, $allowed, true ) ? $value : $default; |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* @param mixed $value Value. |
| 383 |
* @param bool $default Fallback when absent. |
| 384 |
* @return bool |
| 385 |
*/ |
| 386 |
private static function bool( $value, $default ) { |
| 387 |
if ( null === $value ) { |
| 388 |
return (bool) $default; |
| 389 |
} |
| 390 |
|
| 391 |
return filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) ?? (bool) $default; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Validate the business details that fill the template's own site_info fields. |
| 396 |
* |
| 397 |
* @param mixed $value Raw site_info. |
| 398 |
* @return array |
| 399 |
*/ |
| 400 |
private static function site_info( $value ) { |
| 401 |
if ( ! is_array( $value ) ) { |
| 402 |
return array(); |
| 403 |
} |
| 404 |
|
| 405 |
$info = array(); |
| 406 |
|
| 407 |
foreach ( self::$site_info_keys as $key ) { |
| 408 |
if ( ! array_key_exists( $key, $value ) ) { |
| 409 |
continue; |
| 410 |
} |
| 411 |
|
| 412 |
if ( 'social_links' === $key ) { |
| 413 |
$info['social_links'] = self::social_links( $value[ $key ] ); |
| 414 |
continue; |
| 415 |
} |
| 416 |
|
| 417 |
if ( 'email' === $key ) { |
| 418 |
$email = is_string( $value[ $key ] ) ? sanitize_email( $value[ $key ] ) : ''; |
| 419 |
|
| 420 |
/** a malformed address is dropped, not stored — it would render on the page */ |
| 421 |
$info['email'] = is_email( $email ) ? $email : ''; |
| 422 |
continue; |
| 423 |
} |
| 424 |
|
| 425 |
if ( 'logo' === $key ) { |
| 426 |
$info['logo'] = self::logo( $value[ $key ] ); |
| 427 |
continue; |
| 428 |
} |
| 429 |
|
| 430 |
if ( 'address' === $key ) { |
| 431 |
$info['address'] = self::description( $value[ $key ] ); |
| 432 |
continue; |
| 433 |
} |
| 434 |
|
| 435 |
$info[ $key ] = self::text( $value[ $key ], 200 ); |
| 436 |
} |
| 437 |
|
| 438 |
return $info; |
| 439 |
} |
| 440 |
|
| 441 |
/** |
| 442 |
* @param mixed $value Raw social links. |
| 443 |
* @return array |
| 444 |
*/ |
| 445 |
private static function social_links( $value ) { |
| 446 |
if ( ! is_array( $value ) ) { |
| 447 |
return array(); |
| 448 |
} |
| 449 |
|
| 450 |
$links = array(); |
| 451 |
|
| 452 |
foreach ( self::$social_keys as $network ) { |
| 453 |
if ( empty( $value[ $network ] ) || ! is_string( $value[ $network ] ) ) { |
| 454 |
continue; |
| 455 |
} |
| 456 |
|
| 457 |
$url = esc_url_raw( trim( $value[ $network ] ) ); |
| 458 |
|
| 459 |
if ( '' !== $url ) { |
| 460 |
$links[ $network ] = $url; |
| 461 |
} |
| 462 |
} |
| 463 |
|
| 464 |
return $links; |
| 465 |
} |
| 466 |
|
| 467 |
/** |
| 468 |
* Validate one image URL, refusing anything the SSRF guard rejects. |
| 469 |
* |
| 470 |
* @param mixed $value Raw URL. |
| 471 |
* @return string Empty string when unusable. |
| 472 |
*/ |
| 473 |
/** |
| 474 |
* The site logo, which is a MAP of variants — not a single URL. |
| 475 |
* |
| 476 |
* The wizard sends `{ default: '<url>' }` and may send more keys beside it |
| 477 |
* (`build_site_info()` in import_temp_start.jsx), and the importer reads them by name: |
| 478 |
* `site_obj.site_info?.logo?.default`, then `site_obj.site_info?.logo?.[wdkitai_site_logo]` |
| 479 |
* for a block that asks for a specific variant. That map IS the contract. |
| 480 |
* |
| 481 |
* This used to go straight to image_url(), which starts with `! is_string( $value )` and |
| 482 |
* therefore returned '' for every object it was handed — so the logo was dropped from the |
| 483 |
* context on its way in, silently, on every single run. The chat said "Logo uploaded ✓", |
| 484 |
* the kit had four blocks marked `site_logo` waiting for it, and the imported site kept |
| 485 |
* the kit's own logo. The browser path never had this problem because it reads `site_obj` |
| 486 |
* directly and no PHP context stands between the two. |
| 487 |
* |
| 488 |
* A plain string is still accepted and normalised to `{ default: … }`, so a caller that |
| 489 |
* only ever had one URL (the bridge, MCP, WP-CLI) keeps working and gains the map shape |
| 490 |
* the importer wants. |
| 491 |
* |
| 492 |
* @since 2.7.2 |
| 493 |
* |
| 494 |
* @param mixed $value Raw logo — map of variants, or a single URL. |
| 495 |
* @return array|string Map of variant => URL, or '' when nothing usable survived. |
| 496 |
*/ |
| 497 |
private static function logo( $value ) { |
| 498 |
if ( is_string( $value ) ) { |
| 499 |
$url = self::image_url( $value ); |
| 500 |
|
| 501 |
return ( '' === $url ) ? '' : array( 'default' => $url ); |
| 502 |
} |
| 503 |
|
| 504 |
if ( ! is_array( $value ) ) { |
| 505 |
return ''; |
| 506 |
} |
| 507 |
|
| 508 |
$out = array(); |
| 509 |
|
| 510 |
foreach ( $value as $variant => $url ) { |
| 511 |
/* Variant names travel into the block payload, so they are held to the same |
| 512 |
* shape as every other key the importer looks up by name. */ |
| 513 |
$variant = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $variant ); |
| 514 |
|
| 515 |
if ( '' === $variant ) { |
| 516 |
continue; |
| 517 |
} |
| 518 |
|
| 519 |
$clean = self::image_url( $url ); |
| 520 |
|
| 521 |
if ( '' !== $clean ) { |
| 522 |
$out[ $variant ] = $clean; |
| 523 |
} |
| 524 |
} |
| 525 |
|
| 526 |
return empty( $out ) ? '' : $out; |
| 527 |
} |
| 528 |
|
| 529 |
private static function image_url( $value ) { |
| 530 |
if ( ! is_string( $value ) || '' === trim( $value ) ) { |
| 531 |
return ''; |
| 532 |
} |
| 533 |
|
| 534 |
$url = esc_url_raw( trim( $value ) ); |
| 535 |
|
| 536 |
if ( '' === $url ) { |
| 537 |
return ''; |
| 538 |
} |
| 539 |
|
| 540 |
/** local uploads are fine and would otherwise be refused as a private address */ |
| 541 |
$uploads = wp_get_upload_dir(); |
| 542 |
|
| 543 |
if ( ! empty( $uploads['baseurl'] ) && 0 === strpos( $url, $uploads['baseurl'] ) ) { |
| 544 |
return $url; |
| 545 |
} |
| 546 |
|
| 547 |
if ( function_exists( 'wdesignkit_validate_external_url' ) && ! wdesignkit_validate_external_url( $url ) ) { |
| 548 |
return ''; |
| 549 |
} |
| 550 |
|
| 551 |
return $url; |
| 552 |
} |
| 553 |
|
| 554 |
/** |
| 555 |
* Validate the chosen image pool. |
| 556 |
* |
| 557 |
* Accepts either bare URLs or {url, width, height} records, since the wizard carries |
| 558 |
* dimensions it already knows and a runner may not. |
| 559 |
* |
| 560 |
* @param mixed $value Raw images. |
| 561 |
* @return array[] |
| 562 |
*/ |
| 563 |
private static function images( $value ) { |
| 564 |
if ( ! is_array( $value ) ) { |
| 565 |
return array(); |
| 566 |
} |
| 567 |
|
| 568 |
$images = array(); |
| 569 |
|
| 570 |
foreach ( $value as $entry ) { |
| 571 |
$url = is_array( $entry ) ? ( isset( $entry['url'] ) ? $entry['url'] : '' ) : $entry; |
| 572 |
$url = self::image_url( $url ); |
| 573 |
|
| 574 |
if ( '' === $url ) { |
| 575 |
continue; |
| 576 |
} |
| 577 |
|
| 578 |
$image = array( 'url' => $url ); |
| 579 |
|
| 580 |
if ( is_array( $entry ) ) { |
| 581 |
if ( ! empty( $entry['width'] ) ) { |
| 582 |
$image['width'] = (int) $entry['width']; |
| 583 |
} |
| 584 |
|
| 585 |
if ( ! empty( $entry['height'] ) ) { |
| 586 |
$image['height'] = (int) $entry['height']; |
| 587 |
} |
| 588 |
} |
| 589 |
|
| 590 |
$images[] = $image; |
| 591 |
|
| 592 |
/** a pool larger than this is a payload problem, not a design choice */ |
| 593 |
if ( count( $images ) >= 60 ) { |
| 594 |
break; |
| 595 |
} |
| 596 |
} |
| 597 |
|
| 598 |
return $images; |
| 599 |
} |
| 600 |
|
| 601 |
/** |
| 602 |
* Validate the list of templates to import. |
| 603 |
* |
| 604 |
* @param mixed $value Raw templates. |
| 605 |
* @return array[] |
| 606 |
*/ |
| 607 |
private static function templates( $value ) { |
| 608 |
if ( ! is_array( $value ) ) { |
| 609 |
return array(); |
| 610 |
} |
| 611 |
|
| 612 |
$templates = array(); |
| 613 |
|
| 614 |
foreach ( $value as $entry ) { |
| 615 |
if ( ! is_array( $entry ) || empty( $entry['id'] ) ) { |
| 616 |
continue; |
| 617 |
} |
| 618 |
|
| 619 |
$templates[] = array( |
| 620 |
'id' => self::text( $entry['id'], 64 ), |
| 621 |
'title' => self::text( isset( $entry['title'] ) ? $entry['title'] : '', 200 ), |
| 622 |
'type' => self::enum( isset( $entry['type'] ) ? $entry['type'] : 'page', array( 'page', 'section' ), 'page' ), |
| 623 |
'wp_post_type' => self::text( isset( $entry['wp_post_type'] ) ? $entry['wp_post_type'] : 'page', 32 ), |
| 624 |
|
| 625 |
/* The template's URL on the demo site, kept so the finalize stage can point |
| 626 |
* the kit's internal links at the imported pages instead. Run through |
| 627 |
* esc_url_raw(), and only ever used as the SEARCH half of a replacement — |
| 628 |
* never emitted into content — so a hostile value can at worst fail to |
| 629 |
* match. */ |
| 630 |
'post_url' => self::url( isset( $entry['post_url'] ) ? $entry['post_url'] : '' ), |
| 631 |
|
| 632 |
/* Catalogue ids for this template's plugin dependencies. Kept because |
| 633 |
* dependency resolution reads them, and safe to keep because they are ids |
| 634 |
* that must match the catalogue — not names, slugs or paths. An id the |
| 635 |
* catalogue does not know is dropped during resolution. */ |
| 636 |
'plugins_id' => self::id_list( isset( $entry['plugins_id'] ) ? $entry['plugins_id'] : array() ), |
| 637 |
); |
| 638 |
} |
| 639 |
|
| 640 |
return $templates; |
| 641 |
} |
| 642 |
|
| 643 |
/** |
| 644 |
* Validate the plugin catalogue. |
| 645 |
* |
| 646 |
* Only the fields Wdkit_Depends_Installer reads are kept, and `type` is clamped to |
| 647 |
* plugin|theme so a catalogue entry can never route a theme through the plugin |
| 648 |
* installer. Entries without a `p_id` are dropped — an entry no template can reference |
| 649 |
* is dead weight at best. |
| 650 |
* |
| 651 |
* @param mixed $value Raw catalogue. |
| 652 |
* @return array[] |
| 653 |
*/ |
| 654 |
private static function plugin_catalogue( $value ) { |
| 655 |
if ( ! is_array( $value ) ) { |
| 656 |
return array(); |
| 657 |
} |
| 658 |
|
| 659 |
$catalogue = array(); |
| 660 |
|
| 661 |
foreach ( $value as $entry ) { |
| 662 |
$entry = is_object( $entry ) ? (array) $entry : $entry; |
| 663 |
|
| 664 |
if ( ! is_array( $entry ) || ! isset( $entry['p_id'] ) || ! is_numeric( $entry['p_id'] ) ) { |
| 665 |
continue; |
| 666 |
} |
| 667 |
|
| 668 |
$type = isset( $entry['type'] ) ? self::text( $entry['type'], 12 ) : 'plugin'; |
| 669 |
|
| 670 |
$catalogue[] = array( |
| 671 |
'p_id' => (int) $entry['p_id'], |
| 672 |
'plugin_name' => self::text( isset( $entry['plugin_name'] ) ? $entry['plugin_name'] : '', 200 ), |
| 673 |
'plugin_slug' => self::plugin_path( isset( $entry['plugin_slug'] ) ? $entry['plugin_slug'] : '' ), |
| 674 |
'original_slug' => self::slug( isset( $entry['original_slug'] ) ? $entry['original_slug'] : '' ), |
| 675 |
'freepro' => isset( $entry['freepro'] ) && is_numeric( $entry['freepro'] ) ? (int) $entry['freepro'] : 0, |
| 676 |
'plugin_builder' => self::text( isset( $entry['plugin_builder'] ) ? $entry['plugin_builder'] : '', 60 ), |
| 677 |
'type' => in_array( $type, array( 'plugin', 'theme' ), true ) ? $type : 'plugin', |
| 678 |
); |
| 679 |
|
| 680 |
if ( count( $catalogue ) >= 300 ) { |
| 681 |
break; |
| 682 |
} |
| 683 |
} |
| 684 |
|
| 685 |
return $catalogue; |
| 686 |
} |
| 687 |
|
| 688 |
/** |
| 689 |
* A plugin slug, as WordPress writes them: `dir/file.php`. |
| 690 |
* |
| 691 |
* Rejects traversal and anything that is not a plain relative path, because this value |
| 692 |
* reaches is_plugin_active() and the installer. |
| 693 |
* |
| 694 |
* @param mixed $value Raw slug. |
| 695 |
* @return string |
| 696 |
*/ |
| 697 |
private static function plugin_path( $value ) { |
| 698 |
if ( ! is_string( $value ) ) { |
| 699 |
return ''; |
| 700 |
} |
| 701 |
|
| 702 |
$value = trim( $value ); |
| 703 |
|
| 704 |
if ( '' === $value || false !== strpos( $value, '..' ) || '/' === $value[0] ) { |
| 705 |
return ''; |
| 706 |
} |
| 707 |
|
| 708 |
if ( 1 !== preg_match( '#^[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+\.php$#', $value ) ) { |
| 709 |
return ''; |
| 710 |
} |
| 711 |
|
| 712 |
return $value; |
| 713 |
} |
| 714 |
|
| 715 |
/** |
| 716 |
* A wordpress.org-style slug: lowercase letters, digits and dashes. |
| 717 |
* |
| 718 |
* @param mixed $value Raw slug. |
| 719 |
* @return string |
| 720 |
*/ |
| 721 |
private static function slug( $value ) { |
| 722 |
if ( ! is_string( $value ) ) { |
| 723 |
return ''; |
| 724 |
} |
| 725 |
|
| 726 |
$value = strtolower( trim( $value ) ); |
| 727 |
|
| 728 |
return ( 1 === preg_match( '/^[a-z0-9\-]{1,80}$/', $value ) ) ? $value : ''; |
| 729 |
} |
| 730 |
|
| 731 |
/** |
| 732 |
* Normalise a list of numeric catalogue ids. |
| 733 |
* |
| 734 |
* @param mixed $value Raw list. |
| 735 |
* @return int[] |
| 736 |
*/ |
| 737 |
private static function id_list( $value ) { |
| 738 |
if ( ! is_array( $value ) ) { |
| 739 |
$value = ( is_numeric( $value ) ) ? array( $value ) : array(); |
| 740 |
} |
| 741 |
|
| 742 |
$ids = array(); |
| 743 |
|
| 744 |
foreach ( $value as $id ) { |
| 745 |
if ( ! is_numeric( $id ) ) { |
| 746 |
continue; |
| 747 |
} |
| 748 |
|
| 749 |
$ids[] = (int) $id; |
| 750 |
} |
| 751 |
|
| 752 |
return array_values( array_unique( $ids ) ); |
| 753 |
} |
| 754 |
|
| 755 |
/** |
| 756 |
* Is this context complete enough for an AI import? |
| 757 |
* |
| 758 |
* Mirrors the wizard's own gate and the server's requirement: `site_type` and |
| 759 |
* `site_description` are the two fields AIController@next_check_ai_request rejects an |
| 760 |
* empty value for. |
| 761 |
* |
| 762 |
* @param array $context Normalised context. |
| 763 |
* @return bool |
| 764 |
*/ |
| 765 |
public static function is_ai_ready( $context ) { |
| 766 |
return ! empty( $context['site_type'] ) && ! empty( $context['site_description'] ); |
| 767 |
} |
| 768 |
|
| 769 |
/** |
| 770 |
* Human-readable reasons a context cannot be run, for the session record. |
| 771 |
* |
| 772 |
* @param array $context Normalised context. |
| 773 |
* @return string[] |
| 774 |
*/ |
| 775 |
public static function validation_errors( $context ) { |
| 776 |
$errors = array(); |
| 777 |
|
| 778 |
if ( empty( $context['kit_id'] ) ) { |
| 779 |
$errors[] = __( 'kit_id is required.', 'wdesignkit' ); |
| 780 |
} |
| 781 |
|
| 782 |
if ( empty( $context['templates'] ) ) { |
| 783 |
$errors[] = __( 'At least one template is required.', 'wdesignkit' ); |
| 784 |
} |
| 785 |
|
| 786 |
if ( 'ai_import' === $context['import_type'] && ! self::is_ai_ready( $context ) ) { |
| 787 |
$errors[] = __( 'An AI import needs both a site type and a description.', 'wdesignkit' ); |
| 788 |
} |
| 789 |
|
| 790 |
return $errors; |
| 791 |
} |
| 792 |
} |
| 793 |
} |
| 794 |
|