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 +1051 -84 3.0.63.8.0 View file →
@@ -1,11 +1,16 @@
1 1 <?php
2 +
2 3 namespace Templately\Utils;
3 4
5 +use Elementor\Plugin;
6 +use Templately\Modules\FullSiteImport\Utils\Utils;
7 +use Templately\Utils\Response\ResponseNormalizer;
4 8 use WP_Error;
5 9 use WP_REST_Response;
6 10 use function get_plugins;
7 11 use function is_plugin_active;
12 +
8 13 /**
9 14 * Utility Helper for Templately
10 15 *
11 16 * This class contains some helper functions for easy access.
@@ -12,66 +17,454 @@
12 17 *
13 18 * @since 1.0.0
14 19 */
15 20 class Helper extends Base {
16 - /**
17 - * Get installed WordPress Plugin List
18 - * @return array
19 - */
20 - public static function get_plugins(){
21 - if( ! function_exists( 'get_plugins' ) ) {
22 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
23 - }
24 - return get_plugins();
25 - }
21 + /**
22 + * Check if development API should be used
23 + *
24 + * @return bool True if development API should be used
25 + */
26 + public static function is_dev_api(){
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 + }
38 + return defined( 'TEMPLATELY_DEV_API' ) && constant( 'TEMPLATELY_DEV_API' );
39 + }
26 40
27 - /**
28 - * Get installed WordPress Plugin List
29 - * @return boolean
30 - */
31 - public static function is_plugin_active( $plugin ){
32 - if( ! function_exists( 'is_plugin_active' ) ) {
33 - require_once ABSPATH . 'wp-admin/includes/plugin.php';
34 - }
35 - return is_plugin_active( $plugin );
36 - }
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 = '';
37 53
38 - /**
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 + /**
77 + * Get installed WordPress Plugin List
78 + * @return array
79 + */
80 + public static function get_plugins() {
81 + if (! function_exists('get_plugins')) {
82 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
83 + }
84 + return get_plugins();
85 + }
86 + public static function is_plugins_installed($plugin_file) {
87 + $_plugins = self::get_plugins();
88 + $is_installed = isset($_plugins[$plugin_file]);
89 + return $is_installed;
90 + }
91 +
92 + /**
93 + * Get installed WordPress Plugin List
94 + * @return boolean
95 + */
96 + public static function is_plugin_active($plugin) {
97 + if (! function_exists('is_plugin_active')) {
98 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
99 + }
100 + return is_plugin_active($plugin);
101 + }
102 +
103 + /**
39 104 * Collect IP from request.
40 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 + *
41 111 * @return string
42 112 */
43 113 public static function get_ip() {
44 - $ip = '127.0.0.1'; // Local IP
45 - if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
46 - $ip = $_SERVER['HTTP_CLIENT_IP'];
47 - } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
48 - $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
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;
118 + }
119 +
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';
134 + }
135 +
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 + /**
147 + * Get views for front-end display
148 + *
149 + * @param string $name it will be file name only from the view's folder.
150 + * @param array $data
151 + * @return void
152 + */
153 + public static function views($name, $data = []) {
154 + extract($data);
155 + $helper = self::class;
156 + $file = TEMPLATELY_PATH . 'views/' . $name . '.php';
157 +
158 + if (is_readable($file)) {
159 + include_once $file;
160 + }
161 + }
162 +
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 + /**
185 + * Get API URL for Templately endpoints
186 + *
187 + * @param string $endpoint API endpoint path (e.g., 'v2/import/pack/123')
188 + * @return string Complete API URL
189 + */
190 + public static function get_api_url($endpoint): string {
191 + $base_url = self::is_dev_api() ? 'https://app.templately.dev' : 'https://app.templately.com';
192 +
193 + /**
194 + * Filter the base URL for development API
195 + *
196 + * @since 3.5.0
197 + * @param string $base_url The default base URL
198 + */
199 + $base_url = apply_filters('templately_dev_api_base_url', $base_url);
200 +
201 + return "{$base_url}/api/{$endpoint}";
202 + }
203 +
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 + /**
265 + * Make a unified API request to Templately API
266 + *
267 + * @param string $method HTTP method (GET or POST)
268 + * @param string $api_url Complete API URL
269 + * @param array $body Request body data (for POST requests)
270 + * @param array $extra_headers Additional headers beyond the standard ones
271 + * @param int $timeout Request timeout in seconds (default: 30)
272 + * @return array|WP_Error Response array or WP_Error on failure
273 + */
274 + private static function make_api_request($method, $api_url, $body = [], $extra_headers = [], $timeout = 30) {
275 + $api_key = Options::get_instance()->get('api_key');
276 +
277 + $headers = [
278 + 'Authorization' => 'Bearer ' . $api_key,
279 + 'x-templately-ip' => self::get_ip(),
280 + 'x-templately-url' => home_url('/'),
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',
287 + ];
288 +
289 + // Add Content-Type for POST requests
290 + if (strtoupper($method) === 'POST') {
291 + $headers['Content-Type'] = 'application/json';
292 + }
293 +
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();
299 + }
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 +
310 + // Merge additional headers
311 + $headers = array_merge($headers, $extra_headers);
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 +
321 + $args = [
322 + 'timeout' => $timeout,
323 + 'headers' => $headers,
324 + ];
325 +
326 + // Apply filter to allow network admin or other functionality to modify request args
327 + $args = apply_filters( 'templately_api_request_params', $args, $method, $api_url );
328 +
329 + // Add body for POST requests
330 + if (strtoupper($method) === 'POST') {
331 + $args['body'] = is_array($body) ? json_encode($body) : $body;
332 + }
333 +
334 + // Make the appropriate request
335 + if (strtoupper($method) === 'POST') {
336 + $response = wp_remote_post($api_url, $args);
49 337 } else {
50 - $ip = ! empty( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : $ip;
338 + $response = wp_remote_get($api_url, $args);
51 339 }
52 340
53 - return sanitize_text_field( $ip );
341 + // Check for verification header in the response
342 + self::check_verification_header($response);
343 +
344 +
345 + // Check for site disconnection in response body
346 + self::check_site_disconnection($response);
347 +
348 + return $response;
54 349 }
55 350
56 - /**
57 - * Get views for front-end display
58 - *
59 - * @param string $name it will be file name only from the view's folder.
60 - * @param array $data
61 - * @return void
62 - */
63 - public static function views( $name, $data = [] ){
64 - extract( $data );
65 - $helper = self::class;
66 - $file = TEMPLATELY_PATH . 'views/' . $name . '.php';
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 + }
67 376
68 - if( is_readable( $file ) ) {
69 - include_once $file;
70 - }
71 - }
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 + }
72 393
73 394 /**
395 + * Make a GET request to Templately API
396 + *
397 + * @param string $endpoint API endpoint path (e.g., 'v2/import/pack/123')
398 + * @param array $query_params Query parameters as key-value pairs
399 + * @param array $extra_headers Additional headers beyond the standard ones
400 + * @param int $timeout Request timeout in seconds (default: 30)
401 + * @return array|WP_Error Response array or WP_Error on failure
402 + */
403 + public static function make_api_get_request($endpoint, $query_params = [], $extra_headers = [], $timeout = 30) {
404 + $api_url = self::get_api_url($endpoint);
405 +
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.
415 + if (!empty($query_params)) {
416 + $separator = false === strpos($api_url, '?') ? '?' : '&';
417 + $api_url .= $separator . http_build_query($query_params, '', '&', PHP_QUERY_RFC3986);
418 + }
419 +
420 + return self::make_api_request('GET', $api_url, [], $extra_headers, $timeout);
421 + }
422 +
423 + /**
424 + * Make a POST request to Templately API
425 + *
426 + * @param string $endpoint API endpoint path (e.g., 'v2/feedback/store')
427 + * @param array $body Request body data
428 + * @param array $extra_headers Additional headers beyond the standard ones
429 + * @param int $timeout Request timeout in seconds (default: 30)
430 + * @return array|WP_Error Response array or WP_Error on failure
431 + */
432 + public static function make_api_post_request($endpoint, $body = [], $extra_headers = [], $timeout = 30) {
433 + $api_url = self::get_api_url($endpoint);
434 + return self::make_api_request('POST', $api_url, $body, $extra_headers, $timeout);
435 + }
436 +
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 + /**
74 467 * Sanitize Helper
75 468 *
76 469 * @param mixed $value
77 470 * @param string $type
@@ -77,15 +470,15 @@
77 470 * @param string $type
78 471 *
79 472 * @return bool|string
80 473 */
81 - public static function sanitize( $value, $type = 'text' ){
82 - switch ( $type ) {
474 + public static function sanitize($value, $type = 'text') {
475 + switch ($type) {
83 476 case 'boolean':
84 - $sanitized_value = rest_sanitize_boolean( $value );
477 + $sanitized_value = rest_sanitize_boolean($value);
85 478 break;
86 479 default:
87 - $sanitized_value = sanitize_text_field( $value );
480 + $sanitized_value = sanitize_text_field($value);
88 481 break;
89 482 }
90 483
91 484 return $sanitized_value;
@@ -91,8 +484,208 @@
91 484 return $sanitized_value;
92 485 }
93 486
94 487 /**
488 + * Escape a string for safe embedding inside a GraphQL or JSON string literal.
489 + *
490 + * GraphQL string escaping rules are identical to JSON string escaping (per the
491 + * GraphQL spec), so wp_json_encode() is the authoritative escaper. We strip the
492 + * outer quotes it adds and return only the escaped inner content, ready to be
493 + * wrapped in your own quote pair.
494 + *
495 + * Handles pre-encoded JSON: when the caller has already run json_encode() +
496 + * wp_slash() on a value (e.g. categories, dependencies in Items.php), the
497 + * quotes are already escaped as \" and the string is ready to embed. Calling
498 + * wp_json_encode() again would double-escape those backslashes. We detect this
499 + * case by checking whether wp_unslash() produces valid JSON, and if so, return
500 + * the value directly without further encoding.
501 + *
502 + * @param string $value Raw string or wp_slash(json_encode()) output.
503 + * @return string Escaped string, safe to place between double quotes in GraphQL/JSON.
504 + */
505 + public static function esc_json_string( $value ) {
506 + $value = (string) $value;
507 +
508 + // If wp_slash() was applied to a JSON string upstream, the quotes are
509 + // already escaped (e.g. {\"key\":\"val\"}). Detect this by unslashing and
510 + // checking for valid JSON — if it matches, the value is already suitable
511 + // for embedding in a string literal; return it as-is to avoid doubling backslashes.
512 + $unslashed = wp_unslash( $value );
513 + if ( $unslashed !== $value ) {
514 + $decoded = json_decode( $unslashed, true );
515 + if ( json_last_error() === JSON_ERROR_NONE && null !== $decoded ) {
516 + return $value;
517 + }
518 + }
519 +
520 + $encoded = wp_json_encode( $value );
521 + // wp_json_encode wraps the value in "...", strip those outer quotes.
522 + return substr( $encoded, 1, -1 );
523 + }
524 +
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 + /**
551 + * Check for X-Templately-Verified header and update user verification status
552 + *
553 + * @param array|WP_Error $response The HTTP response array from wp_remote_get/wp_remote_post
554 + * @return void
555 + */
556 + public static function check_verification_header($response) {
557 + // Only process if response is not a WP_Error and contains headers
558 + if (is_wp_error($response)) {
559 + return;
560 + }
561 +
562 + // Retrieve the X-Templately-Verified header
563 + $verification_header = wp_remote_retrieve_header($response, 'X-Templately-Verified');
564 +
565 + // Check if header exists and has a truthy value.
566 + // An empty/absent header means "no change" — never "unverified" (043 D6).
567 + if (!empty($verification_header) && filter_var($verification_header, FILTER_VALIDATE_BOOLEAN)) {
568 + try {
569 + $user = self::mark_user_verified();
570 +
571 + if (!empty($user['is_verified'])){
572 + if(!headers_sent()){
573 + header( 'X-Templately-Verified: true' );
574 + if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
575 + self::log('User verification status already updated via X-Templately-Verified header');
576 + }
577 + }
578 +
579 + return true;
580 + }
581 + } catch (\Exception $e) {
582 + // Log error if debug logging is enabled
583 + if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
584 + self::log('Error updating user verification status: ' . $e->getMessage());
585 + }
586 + }
587 + }
588 +
589 + return false;
590 + }
591 +
592 + /**
593 + * Check for site disconnection status in API response body
594 + *
595 + * Detects SiteNotConnected errors and updates user disconnection status.
596 + * Sends X-Templately-Disconnected header for frontend detection.
597 + *
598 + *
599 + * @param array|WP_Error|mixed $response The response object or body array
600 + * @return bool True if site is disconnected, false otherwise
601 + */
602 + public static function check_site_disconnection($response) {
603 + if (is_wp_error($response)) {
604 + return false;
605 + }
606 +
607 + $response_body = $response;
608 +
609 + // If it's a raw WP response array with body, decode it
610 + if (is_array($response) && isset($response['body']) && is_string($response['body'])) {
611 + $response_body = json_decode(wp_remote_retrieve_body($response), true);
612 + }
613 +
614 + // Check if response body indicates site disconnection
615 + if (!is_array($response_body)) {
616 + return false;
617 + }
618 +
619 + $status = $response_body['status'] ?? null;
620 + $status_text = $response_body['statusText'] ?? null;
621 +
622 + // Check for SiteNotConnected error
623 + if ($status === 'error' && $status_text === 'SiteNotConnected') {
624 + try {
625 + // Get current user data
626 + $options = Options::get_instance();
627 + $user = $options->get('user');
628 +
629 + // Only update if user data exists
630 + if (!empty($user) && is_array($user)) {
631 + // Set disconnection flag
632 + $user['is_disconnected'] = true;
633 +
634 + // Save updated user data
635 + $options->set('user', $user);
636 +
637 + if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
638 + self::log('Site disconnection detected: SiteNotConnected status');
639 + }
640 + }
641 +
642 + // Send header for frontend detection
643 + if (!headers_sent()) {
644 + header('X-Templately-Disconnected: true');
645 + }
646 +
647 + return true;
648 + } catch (\Exception $e) {
649 + // Log error if debug logging is enabled
650 + if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
651 + self::log('Error updating site disconnection status: ' . $e->getMessage());
652 + }
653 + }
654 + }
655 +
656 + return false;
657 + }
658 +
659 + /**
660 + * Clear site disconnection status
661 + *
662 + * Called after successful site migration to reset the disconnection flag.
663 + *
664 + * @return void
665 + */
666 + public static function clear_site_disconnection() {
667 + try {
668 + $options = Options::get_instance();
669 + $user = $options->get('user');
670 +
671 + if (!empty($user) && is_array($user)) {
672 + $user['site_url'] = base64_encode( home_url('/') );
673 + $user['is_disconnected'] = false;
674 + $options->set('user', $user);
675 +
676 + if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
677 + self::log('Site disconnection status cleared and URL updated.');
678 + }
679 + }
680 + } catch (\Exception $e) {
681 + if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
682 + self::log('Error clearing site disconnection status: ' . $e->getMessage());
683 + }
684 + }
685 + }
686 +
687 + /**
95 688 * API Error Formatter
96 689 *
97 690 * @param int $error_code
98 691 * @param mixed $error_message
@@ -100,15 +693,34 @@
100 693 * @param integer $status
101 694 * @param array $additional_data
102 695 * @return WP_Error
103 696 */
104 - public static function error( $error_code, $error_message, $endpoint = '', $status = 500, $additional_data = [] ) {
105 - $additional_data['status'] = $status;
106 - if ( ! empty( $endpoint ) ) {
697 + public static function error($error_code, $error_message, $endpoint = '', $status = 500, $additional_data = []) {
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 + }
713 + if (! empty($endpoint)) {
107 714 $additional_data['endpoint'] = $endpoint;
108 715 }
109 -
110 - return new WP_Error( $error_code, $error_message, $additional_data );
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.
722 + return new WP_Error($error_code, $error_message, $additional_data);
111 723 }
112 724
113 725 /**
114 726 * API Response Formatter
@@ -115,10 +727,10 @@
115 727 *
116 728 * @param mixed $data
117 729 * @return WP_REST_Response
118 730 */
119 - public static function success( $data ) {
120 - return new WP_REST_Response( $data, 200 );
731 + public static function success($data) {
732 + return new WP_REST_Response($data, 200);
121 733 }
122 734
123 735 /**
124 736 * Normalize Favourites Data
@@ -128,42 +740,42 @@
128 740 * @param boolean $undo
129 741 *
130 742 * @return array
131 743 */
132 - public function normalizeFavourites( $favourites, $_favourites = [], $undo = false ){
133 - if( $undo ) {
134 - $_favourites[ $favourites['type'] ] = array_values(array_filter( $_favourites[ $favourites['type'] ], function( $item ) use( $favourites ) {
744 + public function normalizeFavourites($favourites, $_favourites = [], $undo = false) {
745 + if ($undo) {
746 + $_favourites[$favourites['type']] = array_values(array_filter($_favourites[$favourites['type']], function ($item) use ($favourites) {
135 747 return $item != $favourites['id'];
136 - } ));
748 + }));
137 749 return $_favourites;
138 750 }
139 751
140 - array_map( function( $item ) use ( &$_favourites) {
141 - if( ! is_null( $item ) ) {
752 + array_map(function ($item) use (&$_favourites) {
753 + if (! is_null($item)) {
142 754 $item = (array) $item;
143 - if ( isset( $_favourites[ $item['type'] ] ) ){
144 - $_favourites[ $item['type'] ][] = $item['id'];
755 + if (isset($_favourites[$item['type']])) {
756 + $_favourites[$item['type']][] = $item['id'];
145 757 } else {
146 - $_favourites[ $item['type'] ] = [ $item['id'] ];
758 + $_favourites[$item['type']] = [$item['id']];
147 759 }
148 760 }
149 761 return $_favourites;
150 - }, $favourites );
762 + }, $favourites);
151 763
152 764 return $_favourites;
153 765 }
154 766
155 - public function normalizeReviews( $favourites, $_favourites = [], $undo = false ){
156 - array_map( function( $item ) use ( &$_favourites) {
157 - if( ! is_null( $item ) ) {
767 + public function normalizeReviews($favourites, $_favourites = [], $undo = false) {
768 + array_map(function ($item) use (&$_favourites) {
769 + if (! is_null($item)) {
158 770 $item = (array) $item;
159 - if ( !isset( $_favourites[ $item['type'] ] ) ){
160 - $_favourites[ $item['type'] ] = [];
771 + if (!isset($_favourites[$item['type']])) {
772 + $_favourites[$item['type']] = [];
161 773 }
162 - $_favourites[ $item['type'] ][$item['type_id']] = $item['rating'];
774 + $_favourites[$item['type']][$item['type_id']] = $item['rating'];
163 775 }
164 776 return $_favourites;
165 - }, $favourites );
777 + }, $favourites);
166 778
167 779 return $_favourites;
168 780 }
169 781
@@ -172,33 +784,388 @@
172 784 *
173 785 * @param object $triggered_by
174 786 * @return void
175 787 */
176 - public static function trigger_error( $triggered_by, $method = 'get_instance' ){
177 - $class = get_class( $triggered_by );
788 + public static function trigger_error($triggered_by, $method = 'get_instance') {
789 + $class = get_class($triggered_by);
178 790 $trace = debug_backtrace(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection
179 791 $file = $trace[0]['file'];
180 792 $line = $trace[0]['line'];
181 - 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
182 794 }
183 795
184 796 /**
185 - * 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}.
186 799 *
187 - * @param mixed $log
800 + * @param mixed $log The data to log
801 + * @param string $context Optional context for categorizing log entries
802 + * @param string $level Optional log level (debug, info, warning, error)
188 803 * @return void
189 804 */
190 - public static function log( $log ){
191 - if ( defined('WP_DEBUG_LOG') && WP_DEBUG_LOG === true ) {
192 - if ( is_array( $log ) || is_object( $log ) ) {
193 - error_log( print_r( $log, true ) );
805 + public static function log($log, $context = '', $level = 'info') {
806 + // Allow complete override of logging behavior
807 + $override_result = apply_filters('templately_log_override', null, $log, $context, $level);
808 + if ($override_result !== null) {
809 + return;
810 + }
811 +
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')) {
818 + return;
819 + }
820 +
821 + // Format the log message
822 + $formatted_message = self::format_log_message($log, $context, $level);
823 +
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 + }
856 + }
857 +
858 + /**
859 + * Format log message with context and level
860 + *
861 + * @param mixed $log The data to log
862 + * @param string $context Context for categorizing log entries
863 + * @param string $level Log level
864 + * @return string Formatted log message
865 + */
866 + private static function format_log_message($log, $context = '', $level = 'info') {
867 + // Convert arrays and objects to readable format
868 + if (is_array($log) || is_object($log)) {
869 + $log_content = print_r($log, true);
870 + } else {
871 + $log_content = (string) ($log ?: '');
872 + }
873 +
874 + // Build the formatted message
875 + $timestamp = current_time('Y-m-d H:i:s');
876 + $level_upper = strtoupper($level);
877 +
878 + if (!empty($context)) {
879 + return "[{$timestamp}] [{$level_upper}] [{$context}] {$log_content}";
880 + } else {
881 + return "[{$timestamp}] [{$level_upper}] {$log_content}";
882 + }
883 + }
884 +
885 + public static function should_flush() {
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) {
889 + return false;
890 + }
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;
897 + }
898 +
899 + public static function get_block_by_name($blocks, $search) {
900 + $queue = $blocks;
901 +
902 + while (!empty($queue)) {
903 + $current_block = array_shift($queue);
904 +
905 + if ($search === $current_block['blockName']) {
906 + return $current_block;
907 + }
908 +
909 + if (isset($current_block['innerBlocks'])) {
910 + // Add nested blocks to the end of the queue for processing
911 + $queue = array_merge($queue, $current_block['innerBlocks']);
912 + }
913 + }
914 +
915 + return false;
916 + }
917 +
918 + /**
919 + * Only checks if user can install/activate plugins
920 + *
921 + * @param [type] $cap
922 + * @param [type] ...$args
923 + * @return void
924 + */
925 + public static function current_user_can($cap, ...$args) {
926 + $user = wp_get_current_user();
927 +
928 + // Multisite super admin has all caps by definition, Unless specifically denied.
929 + if (is_multisite() && is_super_admin($user->ID)) {
930 + return true;
931 + }
932 +
933 + $caps = map_meta_cap($cap, $user->ID, ...$args);
934 +
935 + switch ($cap) {
936 + case 'install_plugins':
937 + case 'upload_plugins':
938 + $caps = ['install_plugins'];
939 + break;
940 + case 'install_themes':
941 + case 'upload_themes':
942 + $caps = ['install_themes'];
943 + break;
944 + case 'activate_plugins':
945 + case 'deactivate_plugins':
946 + case 'activate_plugin':
947 + case 'deactivate_plugin':
948 + $caps = ['activate_plugins'];
949 + break;
950 + default:
951 + break;
952 + }
953 +
954 + // Maintain BC for the argument passed to the "user_has_cap" filter.
955 + $args = array_merge(array($cap, $user->ID), $args);
956 +
957 + /**
958 + * See WP_User::has_cap() for description.
959 + */
960 + $capabilities = apply_filters('user_has_cap', $user->allcaps, $caps, $args, $user);
961 +
962 + // Everyone is allowed to exist.
963 + $capabilities['exist'] = true;
964 +
965 + // Nobody is allowed to do things they are not allowed to do.
966 + unset($capabilities['do_not_allow']);
967 +
968 + // Must have ALL requested caps.
969 + foreach ((array) $caps as $cap) {
970 + if (empty($capabilities[$cap])) {
971 + return false;
972 + }
973 + }
974 +
975 + return true;
976 + }
977 +
978 + /**
979 + * Calculates the elapsed time and checks if it is close to the maximum execution time.
980 + * Returns true if the script should exit to avoid exceeding the limit.
981 + *
982 + * @return bool True if the script should exit, false otherwise.
983 + */
984 + public static function fsi_should_exit() {
985 + if (!defined('TEMPLATELY_START_TIME')) {
986 + return false;
987 + }
988 +
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];
1003 + }
1004 +
1005 + return false;
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 +
1015 + return false;
1016 + }
1017 +
1018 + /**
1019 + * Enable Elementor Container
1020 + * This function will enable the Elementor Container feature.
1021 + * Without this feature, some of the templates may not work properly.
1022 + *
1023 + * @return boolean
1024 + */
1025 + public static function enable_elementor_container() {
1026 + if (class_exists('Elementor\Plugin')) {
1027 + $control_name = Plugin::instance()->experiments->get_feature_option_key('container');
1028 + if (get_option($control_name) !== 'active') {
1029 + update_option($control_name, 'active');
1030 + return true;
1031 + }
1032 + }
1033 + return false;
1034 + }
1035 +
1036 + /**
1037 + * Undocumented function
1038 + *
1039 + * @param [type] $args
1040 + * @param [type] $defaults
1041 + * @return array
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 +
1077 + public static function recursive_wp_parse_args($args, $defaults) {
1078 + $args = (array) $args;
1079 + $defaults = (array) $defaults;
1080 + $r = $defaults;
1081 + foreach ($args as $key => $value) {
1082 + if (is_array($value) && isset($r[$key])) {
1083 + // also handle numeric array. if both $value and $r[ $key ] are numeric array. wp_is_numeric_array()
1084 + if (wp_is_numeric_array($value) && wp_is_numeric_array($r[$key])) {
1085 + foreach ($value as $k => $v) {
1086 + if (!in_array($v, $r[$key])) {
1087 + if (!isset($r[$key][$k])) {
1088 + $r[$key][$k] = $v;
1089 + } else {
1090 + $r[$key][] = $v;
1091 + }
1092 + }
1093 + }
1094 + } else {
1095 + $r[$key] = self::recursive_wp_parse_args($value, $r[$key]);
1096 + }
194 1097 } else {
195 - error_log( $log ?: '' );
1098 + $r[$key] = $value;
196 1099 }
197 1100 }
1101 + return $r;
198 1102 }
199 1103
200 - public static function should_flush(){
201 - return (!defined('TEMPLATELY_IGNORE_FLUSH_ALL') || !TEMPLATELY_IGNORE_FLUSH_ALL) && strpos($_SERVER['SERVER_SOFTWARE'], 'LiteSpeed') === false;
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;
202 1147 }
203 1148
204 -}
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 );
1169 + }
1170 +
1171 +}