PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
← All changes | includes/Utils/Helper.php +499 -51 3.6.13.8.0 View file →
@@ -2,9 +2,10 @@
2 2
3 3 namespace Templately\Utils;
4 4
5 5 use Elementor\Plugin;
6 -use Templately\Core\Importer\Utils\Utils;
6 +use Templately\Modules\FullSiteImport\Utils\Utils;
7 +use Templately\Utils\Response\ResponseNormalizer;
7 8 use WP_Error;
8 9 use WP_REST_Response;
9 10 use function get_plugins;
10 11 use function is_plugin_active;
@@ -22,13 +23,58 @@
22 23 *
23 24 * @return bool True if development API should be used
24 25 */
25 26 public static function is_dev_api(){
26 - // Only check TEMPLATELY_DEV_API constant - no fallback mechanisms
27 + // Developer OVERRIDE: a PHP constant can't be redefined at runtime, so when the
28 + // override is explicitly unlocked (`templately_developer_override` option), the
29 + // saved developer setting takes priority over the wp-config constant. Read
30 + // directly (no Developer-class dependency — that module is dev-only) and OFF by
31 + // default, so production behaviour is unchanged (constant-only).
32 + if ( get_option( 'templately_developer_override', false ) ) {
33 + $settings = get_option( 'templately_developer_settings', [] );
34 + if ( is_array( $settings ) && array_key_exists( 'TEMPLATELY_DEV_API', $settings ) ) {
35 + return (bool) $settings['TEMPLATELY_DEV_API'];
36 + }
37 + }
27 38 return defined( 'TEMPLATELY_DEV_API' ) && constant( 'TEMPLATELY_DEV_API' );
28 39 }
29 40
30 41 /**
42 + * The singular post-type label for the content currently being viewed — i.e. what a
43 + * conditioned header/footer/single/archive template is decorating on this request
44 + * ("Post" on a single post, "Page" on a page, "Product" on a shop/product). Null when
45 + * there's no meaningful post type (e.g. 404). Shared so BOTH theme-builder admin bars
46 + * (Elementor `DocumentManager::filter_admin_bar_labels` + Gutenberg `AdminBar`) badge
47 + * the same way — the pill reads the post type, not the template type.
48 + *
49 + * @return string|null
50 + */
51 + public static function current_context_post_type_label(): ?string {
52 + $post_type = '';
53 +
54 + if ( is_singular() ) {
55 + $post_type = get_post_type( get_queried_object_id() );
56 + } elseif ( function_exists( 'is_shop' ) && is_shop() ) {
57 + $post_type = 'product';
58 + } elseif ( is_post_type_archive() ) {
59 + $post_type = get_query_var( 'post_type' );
60 + if ( is_array( $post_type ) ) {
61 + $post_type = reset( $post_type );
62 + }
63 + } elseif ( is_home() ) {
64 + $post_type = 'post';
65 + }
66 +
67 + if ( empty( $post_type ) ) {
68 + return null;
69 + }
70 +
71 + $object = get_post_type_object( $post_type );
72 +
73 + return $object ? $object->labels->singular_name : null;
74 + }
75 +
76 + /**
31 77 * Get installed WordPress Plugin List
32 78 * @return array
33 79 */
34 80 public static function get_plugins() {
@@ -56,24 +102,49 @@
56 102
57 103 /**
58 104 * Collect IP from request.
59 105 *
106 + * Prefers REMOTE_ADDR since it cannot be spoofed by the client. When it is
107 + * a private/reserved address (reverse proxy, Docker bridge gateway like
108 + * 192.168.65.1, local dev), the forwarded headers are scanned for the first
109 + * public IP. If nothing public is found, the request is local: 127.0.0.1.
110 + *
60 111 * @return string
61 112 */
62 113 public static function get_ip() {
63 - $ip = '127.0.0.1'; // Local IP
64 - if (! empty($_SERVER['HTTP_CLIENT_IP'])) {
65 - $ip = $_SERVER['HTTP_CLIENT_IP'];
66 - } elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
67 - $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
68 - } else {
69 - $ip = ! empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : $ip;
114 + $remote_addr = ! empty($_SERVER['REMOTE_ADDR']) ? sanitize_text_field($_SERVER['REMOTE_ADDR']) : '';
115 +
116 + if (self::is_public_ip($remote_addr)) {
117 + return $remote_addr;
70 118 }
71 119
72 - return sanitize_text_field($ip);
120 + foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP'] as $header) {
121 + if (empty($_SERVER[$header])) {
122 + continue;
123 + }
124 + $candidates = explode(',', sanitize_text_field($_SERVER[$header]));
125 + foreach ($candidates as $candidate) {
126 + $candidate = trim($candidate);
127 + if (self::is_public_ip($candidate)) {
128 + return $candidate;
129 + }
130 + }
131 + }
132 +
133 + return '127.0.0.1';
73 134 }
74 135
75 136 /**
137 + * Check whether a string is a valid public (non-private, non-reserved) IP.
138 + *
139 + * @param string $ip
140 + * @return bool
141 + */
142 + private static function is_public_ip($ip): bool {
143 + return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
144 + }
145 +
146 + /**
76 147 * Get views for front-end display
77 148 *
78 149 * @param string $name it will be file name only from the view's folder.
79 150 * @param array $data
@@ -89,8 +160,29 @@
89 160 }
90 161 }
91 162
92 163 /**
164 + * A URL on the public Templately website, honouring the dev domain.
165 + *
166 + * The PHP counterpart of `react-src/utils/helper.ts#webURL`. A hard-coded
167 + * `https://templately.com/...` sends a site running against the dev API to
168 + * the live site, where its account does not exist — so build every out-link
169 + * through this instead.
170 + *
171 + * Note this is the *website*, not the API host `get_api_url()` builds.
172 + *
173 + * @param string $path Path with or without a leading slash.
174 + * @param array $args Query args (utm_* etc).
175 + * @return string
176 + */
177 + public static function web_url( string $path = '', array $args = [] ): string {
178 + $base_url = self::is_dev_api() ? 'https://templately.dev' : 'https://templately.com';
179 + $url = $base_url . '/' . ltrim( $path, '/' );
180 +
181 + return empty( $args ) ? $url : add_query_arg( $args, $url );
182 + }
183 +
184 + /**
93 185 * Get API URL for Templately endpoints
94 186 *
95 187 * @param string $endpoint API endpoint path (e.g., 'v2/import/pack/123')
96 188 * @return string Complete API URL
@@ -109,8 +201,68 @@
109 201 return "{$base_url}/api/{$endpoint}";
110 202 }
111 203
112 204 /**
205 + * Resolve the requesting host platform ('templately' | 'ai-builder' | ...),
206 + * forwarded to the cloud as the `x-templately-requested-platform` header so the
207 + * cloud can scope behaviour per host (e.g. waive the pack-purchase gate for the
208 + * AI Builder onboarding). Resolution order:
209 + * 1. the incoming `X-Templately-Requested-Platform` HTTP header — a host (e.g.
210 + * ai-builder) sets it ONCE via an apiFetch middleware / fetch header, so no
211 + * per-call body param is needed;
212 + * 2. the `requested_platform` request param (back-compat with explicit callers);
213 + * 3. the `templately_requested_platform` filter (programmatic override);
214 + * 4. the fallback (default 'templately').
215 + *
216 + * @param string $fallback Value when nothing else resolves.
217 + * @return string
218 + */
219 + public static function get_requested_platform( $fallback = 'templately' ) {
220 + $platform = '';
221 + if ( ! empty( $_SERVER['HTTP_X_TEMPLATELY_REQUESTED_PLATFORM'] ) ) {
222 + $platform = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_TEMPLATELY_REQUESTED_PLATFORM'] ) );
223 + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- reading a host marker, not acting on form data.
224 + } elseif ( isset( $_REQUEST['requested_platform'] ) ) {
225 + $platform = sanitize_text_field( wp_unslash( $_REQUEST['requested_platform'] ) );
226 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
227 + }
228 +
229 + if ( '' === $platform ) {
230 + $platform = $fallback;
231 + }
232 +
233 + return apply_filters( 'templately_requested_platform', $platform );
234 + }
235 +
236 + /**
237 + * Resolve the entry-point/source marker for the current request — which surface
238 + * initiated the flow (admin SPA, editor toolbar, editor add-section, …). Mirrors
239 + * get_requested_platform(): incoming `X-Templately-Source` header →
240 + * `$_REQUEST['templately_source']` → filter. Empty string means "unknown" and is
241 + * NOT forwarded to the cloud. Values are engagement telemetry only — never branch
242 + * behaviour on them.
243 + *
244 + * @param string $fallback Value when nothing else resolves.
245 + * @return string
246 + */
247 + public static function get_request_source( $fallback = '' ) {
248 + $source = '';
249 + if ( ! empty( $_SERVER['HTTP_X_TEMPLATELY_SOURCE'] ) ) {
250 + $source = sanitize_key( wp_unslash( $_SERVER['HTTP_X_TEMPLATELY_SOURCE'] ) );
251 + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- reading a telemetry marker, not acting on form data.
252 + } elseif ( isset( $_REQUEST['templately_source'] ) ) {
253 + $source = sanitize_key( wp_unslash( $_REQUEST['templately_source'] ) );
254 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
255 + }
256 +
257 + if ( '' === $source ) {
258 + $source = $fallback;
259 + }
260 +
261 + return apply_filters( 'templately_request_source', $source );
262 + }
263 +
264 + /**
113 265 * Make a unified API request to Templately API
114 266 *
115 267 * @param string $method HTTP method (GET or POST)
116 268 * @param string $api_url Complete API URL
@@ -126,8 +278,13 @@
126 278 'Authorization' => 'Bearer ' . $api_key,
127 279 'x-templately-ip' => self::get_ip(),
128 280 'x-templately-url' => home_url('/'),
129 281 'x-templately-version' => defined( 'TEMPLATELY_VERSION' ) ? constant( 'TEMPLATELY_VERSION' ) : '1.0.0',
282 + // Force JSON responses so the cloud returns JSON errors instead of an HTML
283 + // error page (which json_decode() cannot parse). Binary/XML downloads
284 + // (zip pack, attachment WXR) use their own wp_remote_* calls and bypass
285 + // this helper, so they are unaffected. Callers can override via $extra_headers.
286 + 'Accept' => 'application/json',
130 287 ];
131 288
132 289 // Add Content-Type for POST requests
133 290 if (strtoupper($method) === 'POST') {
@@ -133,18 +290,35 @@
133 290 if (strtoupper($method) === 'POST') {
134 291 $headers['Content-Type'] = 'application/json';
135 292 }
136 293
137 - // Resolve requested platform: $_REQUEST wins (frontend-supplied), then caller's extra_headers, then default.
138 - if ( isset( $_REQUEST['requested_platform'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
139 - $extra_headers['x-templately-requested-platform'] = sanitize_text_field( wp_unslash( $_REQUEST['requested_platform'] ) );
140 - } elseif ( ! isset( $extra_headers['x-templately-requested-platform'] ) ) {
141 - $extra_headers['x-templately-requested-platform'] = 'templately';
294 + // Resolve the requesting host platform centrally. A caller-supplied extra_headers
295 + // value still wins (explicit override); otherwise resolve from the incoming
296 + // header / request param / filter (see Helper::get_requested_platform).
297 + if ( ! isset( $extra_headers['x-templately-requested-platform'] ) ) {
298 + $extra_headers['x-templately-requested-platform'] = self::get_requested_platform();
142 299 }
143 300
301 + // Entry-point marker (engagement telemetry). A URL QUERY PARAM, not a header:
302 + // the cloud's ordinary access logs capture the request URL, so the marker is
303 + // countable with zero cloud-side code. No separate tracking calls are ever
304 + // made (wp.org policy) — this only annotates requests that happen anyway.
305 + // Only appended when known; absent means "unknown", not an error.
306 + if ( '' !== self::get_request_source() ) {
307 + $api_url = add_query_arg( 'tl_source', self::get_request_source(), $api_url );
308 + }
309 +
144 310 // Merge additional headers
145 311 $headers = array_merge($headers, $extra_headers);
146 312
313 + // A request the HTTP layer is allowed to spend $timeout seconds on can still
314 + // be killed by PHP's OWN max_execution_time, which is 30s on a great many
315 + // hosts. When that happens there is no WP_Error and no response to inspect:
316 + // PHP fatals mid-request, WordPress answers with its HTML "critical error"
317 + // page, and a JSON client renders that markup as if it were content. Give
318 + // PHP enough runway for the timeout we are about to ask for.
319 + self::extend_time_limit( $timeout );
320 +
147 321 $args = [
148 322 'timeout' => $timeout,
149 323 'headers' => $headers,
150 324 ];
@@ -174,8 +348,51 @@
174 348 return $response;
175 349 }
176 350
177 351 /**
352 + * GET a Templately API endpoint and return the NORMALIZED result (spec 043).
353 + *
354 + * This is the REST twin of `Http::post()`. Prefer it over
355 + * `make_api_get_request()`: that one hands back the raw `wp_remote_*` array and
356 + * leaves every caller to invent its own "did this fail?" check — which is how
357 + * the plugin ended up reading one server ten different ways.
358 + *
359 + * Side-effects (verification / disconnection) are already applied by
360 + * `make_api_request()`, so the normalizer is told to skip them rather than
361 + * repeat the work.
362 + *
363 + * @param string $endpoint API endpoint path (e.g. 'v2/import/info/pack/123').
364 + * @param array $query_params Query parameters.
365 + * @param array $extra_headers Additional headers.
366 + * @param int $timeout Seconds.
367 + * @param array $options Normalizer options (e.g. [ 'raw' => true ] for binary).
368 + * @return \Templately\Utils\Response\RemoteResponse
369 + */
370 + public static function api_get( $endpoint, $query_params = [], $extra_headers = [], $timeout = 30, $options = [] ) {
371 + return ResponseNormalizer::normalize(
372 + self::make_api_get_request( $endpoint, $query_params, $extra_headers, $timeout ),
373 + array_merge( [ 'side_effects' => false ], $options )
374 + );
375 + }
376 +
377 + /**
378 + * POST to a Templately API endpoint and return the NORMALIZED result (spec 043).
379 + *
380 + * @param string $endpoint API endpoint path.
381 + * @param array $body Request body.
382 + * @param array $extra_headers Additional headers.
383 + * @param int $timeout Seconds.
384 + * @param array $options Normalizer options.
385 + * @return \Templately\Utils\Response\RemoteResponse
386 + */
387 + public static function api_post( $endpoint, $body = [], $extra_headers = [], $timeout = 30, $options = [] ) {
388 + return ResponseNormalizer::normalize(
389 + self::make_api_post_request( $endpoint, $body, $extra_headers, $timeout ),
390 + array_merge( [ 'side_effects' => false ], $options )
391 + );
392 + }
393 +
394 + /**
178 395 * Make a GET request to Templately API
179 396 *
180 397 * @param string $endpoint API endpoint path (e.g., 'v2/import/pack/123')
181 398 * @param array $query_params Query parameters as key-value pairs
@@ -185,11 +402,20 @@
185 402 */
186 403 public static function make_api_get_request($endpoint, $query_params = [], $extra_headers = [], $timeout = 30) {
187 404 $api_url = self::get_api_url($endpoint);
188 405
189 - // Add query parameters if provided
406 + // Add query parameters if provided.
407 + //
408 + // `http_build_query()`, NOT `add_query_arg()`: the latter does not encode the values
409 + // it is given (only the ones already on the URL, via its own `urlencode_deep`), so a
410 + // value carrying `&` silently became two parameters and a value carrying `#` truncated
411 + // the rest of the query into a fragment the server never sees. Every param here comes
412 + // from somewhere a user can type — an image search term, a business description, a hex
413 + // colour — so that is not a theoretical shape. It also flattens arrays as `k[0]=…`,
414 + // which is what PHP on the other end parses back into an array.
190 415 if (!empty($query_params)) {
191 - $api_url = add_query_arg($query_params, $api_url);
416 + $separator = false === strpos($api_url, '?') ? '?' : '&';
417 + $api_url .= $separator . http_build_query($query_params, '', '&', PHP_QUERY_RFC3986);
192 418 }
193 419
194 420 return self::make_api_request('GET', $api_url, [], $extra_headers, $timeout);
195 421 }
@@ -208,8 +434,37 @@
208 434 return self::make_api_request('POST', $api_url, $body, $extra_headers, $timeout);
209 435 }
210 436
211 437 /**
438 + * Ensure PHP will not cut the process short before an HTTP call of $timeout
439 + * seconds can finish.
440 + *
441 + * Only ever RAISES the limit, and only when the configured one is too small;
442 + * a host that has already granted more keeps it. `max_execution_time` of 0
443 + * means "no limit" (CLI, WP-Cron on some setups) and needs nothing. When the
444 + * host has disabled `set_time_limit()`, `function_exists()` is false and we
445 + * leave the request to take its chances rather than emit a warning.
446 + *
447 + * @param int $timeout Seconds the pending HTTP request may take.
448 + * @return void
449 + */
450 + private static function extend_time_limit( $timeout ) {
451 + $limit = (int) ini_get( 'max_execution_time' );
452 +
453 + if ( $limit <= 0 || ! function_exists( 'set_time_limit' ) ) {
454 + return;
455 + }
456 +
457 + // The margin covers everything around the call that also runs on this
458 + // clock: building the payload, and the response handling afterwards.
459 + $needed = (int) $timeout + 30;
460 +
461 + if ( $needed > $limit ) {
462 + @set_time_limit( $needed ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- host may refuse; the request still proceeds.
463 + }
464 + }
465 +
466 + /**
212 467 * Sanitize Helper
213 468 *
214 469 * @param mixed $value
215 470 * @param string $type
@@ -267,8 +522,33 @@
267 522 return substr( $encoded, 1, -1 );
268 523 }
269 524
270 525 /**
526 + * Flip the stored user's `is_verified` flag on.
527 + *
528 + * Extracted for spec 043 so the normalizer can apply the same side-effect
529 + * when verification arrives in the response BODY (the connect mutation)
530 + * rather than in the `X-Templately-Verified` header.
531 + *
532 + * @return array the (possibly updated) user option; empty when no user is stored.
533 + */
534 + public static function mark_user_verified() {
535 + $options = Options::get_instance();
536 + $user = $options->get('user');
537 +
538 + if (empty($user) || !is_array($user)) {
539 + return [];
540 + }
541 +
542 + if (empty($user['is_verified'])) {
543 + $user['is_verified'] = true;
544 + $options->set('user', $user);
545 + }
546 +
547 + return $user;
548 + }
549 +
550 + /**
271 551 * Check for X-Templately-Verified header and update user verification status
272 552 *
273 553 * @param array|WP_Error $response The HTTP response array from wp_remote_get/wp_remote_post
274 554 * @return void
@@ -281,25 +561,14 @@
281 561
282 562 // Retrieve the X-Templately-Verified header
283 563 $verification_header = wp_remote_retrieve_header($response, 'X-Templately-Verified');
284 564
285 - // Check if header exists and has a truthy value
565 + // Check if header exists and has a truthy value.
566 + // An empty/absent header means "no change" — never "unverified" (043 D6).
286 567 if (!empty($verification_header) && filter_var($verification_header, FILTER_VALIDATE_BOOLEAN)) {
287 568 try {
288 - // Get current user data
289 - $options = Options::get_instance();
290 - $user = $options->get('user');
569 + $user = self::mark_user_verified();
291 570
292 - // Only update if user data exists and is not already verified
293 - if (!empty($user) && is_array($user) && empty($user['is_verified'])) {
294 - // Set verification flag
295 - $user['is_verified'] = true;
296 -
297 - // Save updated user data
298 - $options->set('user', $user);
299 -
300 - }
301 -
302 571 if (!empty($user['is_verified'])){
303 572 if(!headers_sent()){
304 573 header( 'X-Templately-Verified: true' );
305 574 if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
@@ -425,16 +694,32 @@
425 694 * @param array $additional_data
426 695 * @return WP_Error
427 696 */
428 697 public static function error($error_code, $error_message, $endpoint = '', $status = 500, $additional_data = []) {
429 - $additional_data['status'] = $status;
698 + // The status reaches WP's REST server as `data.status` and decides the HTTP
699 + // code. A STRING there ("404") makes WP fall back to 500, so a caller that
700 + // passed a numeric string got the wrong status on the wire.
701 + $additional_data['status'] = (int) $status;
702 +
703 + // An array message is field-level validation detail, not a sentence. Left in
704 + // `message` it renders as "Array" to the user; routed to `fields` it is
705 + // structured data the client can attach to the right input (FR-007).
706 + if (is_array($error_message)) {
707 + $additional_data['fields'] = array_merge(
708 + isset($additional_data['fields']) && is_array($additional_data['fields']) ? $additional_data['fields'] : [],
709 + $error_message
710 + );
711 + $error_message = __('Please correct the highlighted fields.', 'templately');
712 + }
430 713 if (! empty($endpoint)) {
431 714 $additional_data['endpoint'] = $endpoint;
432 715 }
433 - // Add browser padding to avoid browsers not serving small JSON responses
434 - $padding_length = 512;
435 - $additional_data['browser_padding'] = str_repeat(' ', $padding_length);
436 -
716 + // Small-response browser padding is NOT added here any more (043 FR-008a).
717 + // It used to staple 512 bytes into every error's data bag unconditionally,
718 + // which put a junk field inside the response contract and padded bodies
719 + // that were never small enough to need it. `RestEnvelope` now applies it
720 + // once, centrally, only below the size threshold, and as a header — so the
721 + // body stays exactly the shape the schema describes.
437 722 return new WP_Error($error_code, $error_message, $additional_data);
438 723 }
439 724
440 725 /**
@@ -504,13 +789,14 @@
504 789 $class = get_class($triggered_by);
505 790 $trace = debug_backtrace(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection
506 791 $file = $trace[0]['file'];
507 792 $line = $trace[0]['line'];
508 - trigger_error("Call to undefined method $class::$method() in $file on line $line", E_USER_ERROR);
793 + trigger_error( esc_html( "Call to undefined method $class::$method() in $file on line $line" ), E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
509 794 }
510 795
511 796 /**
512 - * Printing Error Logs in debug.log file.
797 + * Write a line to Templately's dedicated log file (uploads/templately/log/),
798 + * NOT the site-wide debug.log — see {@see \Templately\Utils\Log\LogFile}.
513 799 *
514 800 * @param mixed $log The data to log
515 801 * @param string $context Optional context for categorizing log entries
516 802 * @param string $level Optional log level (debug, info, warning, error)
@@ -522,10 +808,14 @@
522 808 if ($override_result !== null) {
523 809 return;
524 810 }
525 811
526 - // Only log if WP_DEBUG_LOG is enabled
527 - if (!defined('WP_DEBUG_LOG') || !WP_DEBUG_LOG) {
812 + // The default sink is WP_DEBUG_LOG-gated; additional sinks subscribed to
813 + // the `templately_log` action see entries regardless (a capture module
814 + // must not depend on the file gate). Skip the formatting work entirely
815 + // when nobody would receive the entry.
816 + $gate_open = defined('WP_DEBUG_LOG') && WP_DEBUG_LOG;
817 + if (!$gate_open && !has_action('templately_log')) {
528 818 return;
529 819 }
530 820
531 821 // Format the log message
@@ -530,10 +820,40 @@
530 820
531 821 // Format the log message
532 822 $formatted_message = self::format_log_message($log, $context, $level);
533 823
534 - // Write to error log
535 - error_log($formatted_message);
824 + /**
825 + * Fan-out: every subscribed module receives every entry (http-inspector
826 + * capture, dev console, telemetry…). Listeners are independent sinks —
827 + * this is how MULTIPLE loggers coexist without replacing each other.
828 + *
829 + * @param string $formatted_message The full formatted line.
830 + * @param mixed $log The raw payload passed to log().
831 + * @param string $context Context label.
832 + * @param string $level debug|info|warning|error.
833 + */
834 + do_action('templately_log', $formatted_message, $log, $context, $level);
835 +
836 + if (!$gate_open) {
837 + return;
838 + }
839 +
840 + /**
841 + * The DEFAULT sink, replaceable/decoratable: a filter receives the
842 + * current sink callable and may return a wrapper around it (decorator —
843 + * filter priority defines wrap order) or a substitute. Core's default is
844 + * the dedicated uploads log file (error_log fallback inside). Note:
845 + * entries logged before a module boots (e.g. Modules_Manager discovery
846 + * notices) necessarily use the core default.
847 + *
848 + * @param callable $sink function( string $formatted_line ): void
849 + */
850 + $sink = apply_filters('templately_log_sink', [Log\LogFile::class, 'write']);
851 + if (is_callable($sink)) {
852 + $sink($formatted_message);
853 + } else {
854 + Log\LogFile::write($formatted_message);
855 + }
536 856 }
537 857
538 858 /**
539 859 * Format log message with context and level
@@ -562,12 +882,19 @@
562 882 }
563 883 }
564 884
565 885 public static function should_flush() {
566 - if (isset($_REQUEST['is_lightspeed']) && $_REQUEST['is_lightspeed'] === 'true') {
886 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a read-only capability probe, not a state change.
887 + $is_lightspeed = isset($_REQUEST['is_lightspeed']) ? sanitize_text_field(wp_unslash($_REQUEST['is_lightspeed'])) : '';
888 + if ('true' === $is_lightspeed) {
567 889 return false;
568 890 }
569 - return (!defined('TEMPLATELY_IGNORE_FLUSH_ALL') || !TEMPLATELY_IGNORE_FLUSH_ALL) && strpos($_SERVER['SERVER_SOFTWARE'], 'LiteSpeed') === false;
891 +
892 + // SERVER_SOFTWARE is not guaranteed to be set — some SAPIs (and CLI) omit
893 + // it entirely, and passing null to strpos() is a fatal on PHP 8.1+.
894 + $server_software = isset($_SERVER['SERVER_SOFTWARE']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_SOFTWARE'])) : '';
895 +
896 + return (!defined('TEMPLATELY_IGNORE_FLUSH_ALL') || !TEMPLATELY_IGNORE_FLUSH_ALL) && strpos($server_software, 'LiteSpeed') === false;
570 897 }
571 898
572 899 public static function get_block_by_name($blocks, $search) {
573 900 $queue = $blocks;
@@ -654,18 +981,38 @@
654 981 *
655 982 * @return bool True if the script should exit, false otherwise.
656 983 */
657 984 public static function fsi_should_exit() {
658 - if (defined('TEMPLATELY_START_TIME') && ini_get('max_execution_time')) {
659 - $max_time = ini_get('max_execution_time');
660 - $elapsed = microtime(true) - TEMPLATELY_START_TIME;
661 - $delay = max(5, $max_time * 20 / 100);
985 + if (!defined('TEMPLATELY_START_TIME')) {
986 + return false;
987 + }
662 988
663 - // Check if elapsed time is close to max execution time
664 - if ($max_time - $elapsed <= $delay) {
665 - return ['max_time' => $max_time, 'elapsed' => $elapsed, 'delay' => $delay];
989 + $max_time = (int) ini_get('max_execution_time');
990 + $elapsed = microtime(true) - TEMPLATELY_START_TIME;
991 +
992 + if ($max_time <= 0) {
993 + // The import request calls set_time_limit(0), which zeroes
994 + // max_execution_time — but gateways (FPM, LiteSpeed, nginx proxies)
995 + // still kill the request on THEIR clock, typically at 60s. With no
996 + // PHP limit to lean on, budget against a wall-clock ceiling instead
997 + // so runners keep chunking gracefully (and persisting their
998 + // backup_attributes) instead of dying mid-loop to a 504.
999 + $budget = (int) apply_filters('templately_fsi_request_time_budget', 25);
1000 +
1001 + if ($budget > 0 && $elapsed >= $budget) {
1002 + return ['max_time' => $budget, 'elapsed' => $elapsed, 'delay' => 0];
666 1003 }
1004 +
1005 + return false;
667 1006 }
1007 +
1008 + $delay = max(5, $max_time * 20 / 100);
1009 +
1010 + // Check if elapsed time is close to max execution time
1011 + if ($max_time - $elapsed <= $delay) {
1012 + return ['max_time' => $max_time, 'elapsed' => $elapsed, 'delay' => $delay];
1013 + }
1014 +
668 1015 return false;
669 1016 }
670 1017
671 1018 /**
@@ -692,8 +1039,42 @@
692 1039 * @param [type] $args
693 1040 * @param [type] $defaults
694 1041 * @return array
695 1042 */
1043 + /**
1044 + * Returns true when the current WordPress site is running on a private/loopback
1045 + * host where an external API server cannot push callbacks (localhost, *.local,
1046 + * *.test, RFC-1918 addresses). Used as a fallback when the remote API does not
1047 + * return an explicit is_local_site flag.
1048 + */
1049 + public static function is_local_site(): bool {
1050 + $host = (string) parse_url( get_option( 'siteurl' ), PHP_URL_HOST );
1051 + // Strip port if present (e.g. "localhost:8888")
1052 + $host = (string) preg_replace( '/:\d+$/', '', $host );
1053 +
1054 + if ( in_array( $host, [ 'localhost', '127.0.0.1', '::1' ], true ) ) {
1055 + return true;
1056 + }
1057 + // Common local/dev TLDs that no public DNS resolves (an external API server
1058 + // cannot push callbacks to them): .local/.test (mDNS/RFC-6761), .tst (the
1059 + // WPDeveloper sandbox), .localhost, .dev.
1060 + // `substr()` rather than `str_ends_with()`: the plugin advertises WordPress 5.0 /
1061 + // PHP 7.2, `str_ends_with()` is PHP 8.0+, and core only polyfills it from WP 5.9 —
1062 + // so the call fatals on a host inside our own advertised floor.
1063 + foreach ( [ '.local', '.test', '.tst', '.localhost' ] as $suffix ) {
1064 + if ( substr( $host, - strlen( $suffix ) ) === $suffix ) {
1065 + return true;
1066 + }
1067 + }
1068 + // RFC-1918 private ranges
1069 + if ( preg_match( '/^192\.168\./', $host )
1070 + || preg_match( '/^10\./', $host )
1071 + || preg_match( '/^172\.(1[6-9]|2[0-9]|3[01])\./', $host ) ) {
1072 + return true;
1073 + }
1074 + return false;
1075 + }
1076 +
696 1077 public static function recursive_wp_parse_args($args, $defaults) {
697 1078 $args = (array) $args;
698 1079 $defaults = (array) $defaults;
699 1080 $r = $defaults;
@@ -717,7 +1098,74 @@
717 1098 $r[$key] = $value;
718 1099 }
719 1100 }
720 1101 return $r;
1102 + }
1103 +
1104 + /**
1105 + * Creates the plugin's working directory under wp-uploads and blocks direct
1106 + * web access to it.
1107 + *
1108 + * Everything the importer needs on disk lands here: the extracted pack (its
1109 + * WXR, its template JSON, its attachments), the AI-generated page JSON, and
1110 + * the FSI logs. wp-uploads is web-served, so these paths are not private just
1111 + * because their session id is a uuid — the guards are what makes them
1112 + * unreadable, not the name.
1113 + *
1114 + * .htaccess covers Apache and is inherited by everything below this point;
1115 + * web.config covers IIS; index.php stops a directory listing on any server.
1116 + * nginx honours none of them, so an nginx site still needs a location rule —
1117 + * this raises the floor, it does not replace server configuration.
1118 + *
1119 + * @param string $dir Absolute path to create and protect.
1120 + *
1121 + * @return bool Whether the directory exists and is usable.
1122 + */
1123 + public static function protect_directory( $dir ) {
1124 + if ( empty( $dir ) ) {
1125 + return false;
1126 + }
1127 +
1128 + if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1129 + return false;
1130 + }
1131 +
1132 + $guards = [
1133 + 'index.php' => "<?php\n// Silence is golden.\n",
1134 + '.htaccess' => "# Templately working files — not for direct access.\n<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n\tOrder allow,deny\n\tDeny from all\n</IfModule>\n",
1135 + 'web.config' => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n\t<system.webServer>\n\t\t<authorization>\n\t\t\t<deny users=\"*\" />\n\t\t</authorization>\n\t</system.webServer>\n</configuration>\n",
1136 + ];
1137 +
1138 + foreach ( $guards as $file => $contents ) {
1139 + $path = trailingslashit( $dir ) . $file;
1140 + // Never overwrite: a site owner may have relaxed these deliberately.
1141 + if ( ! file_exists( $path ) ) {
1142 + @file_put_contents( $path, $contents ); // phpcs:ignore
1143 + }
1144 + }
1145 +
1146 + return true;
1147 + }
1148 +
1149 + /**
1150 + * Absolute path to the plugin's protected working directory in wp-uploads.
1151 + *
1152 + * @param string $sub Optional subdirectory ('tmp', 'log', 'preview', ...).
1153 + *
1154 + * @return string Trailing-slashed path, or '' when uploads is unusable.
1155 + */
1156 + public static function upload_dir( $sub = '' ) {
1157 + $upload_dir = wp_upload_dir();
1158 +
1159 + if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) ) {
1160 + return '';
1161 + }
1162 +
1163 + $base = trailingslashit( $upload_dir['basedir'] ) . 'templately' . DIRECTORY_SEPARATOR;
1164 +
1165 + // The guards go on the root so every subdirectory inherits them.
1166 + self::protect_directory( $base );
1167 +
1168 + return '' === $sub ? $base : trailingslashit( $base . $sub );
721 1169 }
722 1170
723 1171 }