labels->singular_name : null;
}
/**
* Get installed WordPress Plugin List
* @return array
*/
public static function get_plugins() {
if (! function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return get_plugins();
}
public static function is_plugins_installed($plugin_file) {
$_plugins = self::get_plugins();
$is_installed = isset($_plugins[$plugin_file]);
return $is_installed;
}
/**
* Get installed WordPress Plugin List
* @return boolean
*/
public static function is_plugin_active($plugin) {
if (! function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return is_plugin_active($plugin);
}
/**
* Collect IP from request.
*
* Prefers REMOTE_ADDR since it cannot be spoofed by the client. When it is
* a private/reserved address (reverse proxy, Docker bridge gateway like
* 192.168.65.1, local dev), the forwarded headers are scanned for the first
* public IP. If nothing public is found, the request is local: 127.0.0.1.
*
* @return string
*/
public static function get_ip() {
$remote_addr = ! empty($_SERVER['REMOTE_ADDR']) ? sanitize_text_field($_SERVER['REMOTE_ADDR']) : '';
if (self::is_public_ip($remote_addr)) {
return $remote_addr;
}
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_CLIENT_IP'] as $header) {
if (empty($_SERVER[$header])) {
continue;
}
$candidates = explode(',', sanitize_text_field($_SERVER[$header]));
foreach ($candidates as $candidate) {
$candidate = trim($candidate);
if (self::is_public_ip($candidate)) {
return $candidate;
}
}
}
return '127.0.0.1';
}
/**
* Check whether a string is a valid public (non-private, non-reserved) IP.
*
* @param string $ip
* @return bool
*/
private static function is_public_ip($ip): bool {
return (bool) filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
}
/**
* Get views for front-end display
*
* @param string $name it will be file name only from the view's folder.
* @param array $data
* @return void
*/
public static function views($name, $data = []) {
extract($data);
$helper = self::class;
$file = TEMPLATELY_PATH . 'views/' . $name . '.php';
if (is_readable($file)) {
include_once $file;
}
}
/**
* A URL on the public Templately website, honouring the dev domain.
*
* The PHP counterpart of `react-src/utils/helper.ts#webURL`. A hard-coded
* `https://templately.com/...` sends a site running against the dev API to
* the live site, where its account does not exist — so build every out-link
* through this instead.
*
* Note this is the *website*, not the API host `get_api_url()` builds.
*
* @param string $path Path with or without a leading slash.
* @param array $args Query args (utm_* etc).
* @return string
*/
public static function web_url( string $path = '', array $args = [] ): string {
$base_url = self::is_dev_api() ? 'https://templately.dev' : 'https://templately.com';
$url = $base_url . '/' . ltrim( $path, '/' );
return empty( $args ) ? $url : add_query_arg( $args, $url );
}
/**
* Get API URL for Templately endpoints
*
* @param string $endpoint API endpoint path (e.g., 'v2/import/pack/123')
* @return string Complete API URL
*/
public static function get_api_url($endpoint): string {
$base_url = self::is_dev_api() ? 'https://app.templately.dev' : 'https://app.templately.com';
/**
* Filter the base URL for development API
*
* @since 3.5.0
* @param string $base_url The default base URL
*/
$base_url = apply_filters('templately_dev_api_base_url', $base_url);
return "{$base_url}/api/{$endpoint}";
}
/**
* Resolve the requesting host platform ('templately' | 'ai-builder' | ...),
* forwarded to the cloud as the `x-templately-requested-platform` header so the
* cloud can scope behaviour per host (e.g. waive the pack-purchase gate for the
* AI Builder onboarding). Resolution order:
* 1. the incoming `X-Templately-Requested-Platform` HTTP header — a host (e.g.
* ai-builder) sets it ONCE via an apiFetch middleware / fetch header, so no
* per-call body param is needed;
* 2. the `requested_platform` request param (back-compat with explicit callers);
* 3. the `templately_requested_platform` filter (programmatic override);
* 4. the fallback (default 'templately').
*
* @param string $fallback Value when nothing else resolves.
* @return string
*/
public static function get_requested_platform( $fallback = 'templately' ) {
$platform = '';
if ( ! empty( $_SERVER['HTTP_X_TEMPLATELY_REQUESTED_PLATFORM'] ) ) {
$platform = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_TEMPLATELY_REQUESTED_PLATFORM'] ) );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- reading a host marker, not acting on form data.
} elseif ( isset( $_REQUEST['requested_platform'] ) ) {
$platform = sanitize_text_field( wp_unslash( $_REQUEST['requested_platform'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
if ( '' === $platform ) {
$platform = $fallback;
}
return apply_filters( 'templately_requested_platform', $platform );
}
/**
* Resolve the entry-point/source marker for the current request — which surface
* initiated the flow (admin SPA, editor toolbar, editor add-section, …). Mirrors
* get_requested_platform(): incoming `X-Templately-Source` header →
* `$_REQUEST['templately_source']` → filter. Empty string means "unknown" and is
* NOT forwarded to the cloud. Values are engagement telemetry only — never branch
* behaviour on them.
*
* @param string $fallback Value when nothing else resolves.
* @return string
*/
public static function get_request_source( $fallback = '' ) {
$source = '';
if ( ! empty( $_SERVER['HTTP_X_TEMPLATELY_SOURCE'] ) ) {
$source = sanitize_key( wp_unslash( $_SERVER['HTTP_X_TEMPLATELY_SOURCE'] ) );
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- reading a telemetry marker, not acting on form data.
} elseif ( isset( $_REQUEST['templately_source'] ) ) {
$source = sanitize_key( wp_unslash( $_REQUEST['templately_source'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
if ( '' === $source ) {
$source = $fallback;
}
return apply_filters( 'templately_request_source', $source );
}
/**
* Make a unified API request to Templately API
*
* @param string $method HTTP method (GET or POST)
* @param string $api_url Complete API URL
* @param array $body Request body data (for POST requests)
* @param array $extra_headers Additional headers beyond the standard ones
* @param int $timeout Request timeout in seconds (default: 30)
* @return array|WP_Error Response array or WP_Error on failure
*/
private static function make_api_request($method, $api_url, $body = [], $extra_headers = [], $timeout = 30) {
$api_key = Options::get_instance()->get('api_key');
$headers = [
'Authorization' => 'Bearer ' . $api_key,
'x-templately-ip' => self::get_ip(),
'x-templately-url' => home_url('/'),
'x-templately-version' => defined( 'TEMPLATELY_VERSION' ) ? constant( 'TEMPLATELY_VERSION' ) : '1.0.0',
// Force JSON responses so the cloud returns JSON errors instead of an HTML
// error page (which json_decode() cannot parse). Binary/XML downloads
// (zip pack, attachment WXR) use their own wp_remote_* calls and bypass
// this helper, so they are unaffected. Callers can override via $extra_headers.
'Accept' => 'application/json',
];
// Add Content-Type for POST requests
if (strtoupper($method) === 'POST') {
$headers['Content-Type'] = 'application/json';
}
// Resolve the requesting host platform centrally. A caller-supplied extra_headers
// value still wins (explicit override); otherwise resolve from the incoming
// header / request param / filter (see Helper::get_requested_platform).
if ( ! isset( $extra_headers['x-templately-requested-platform'] ) ) {
$extra_headers['x-templately-requested-platform'] = self::get_requested_platform();
}
// Entry-point marker (engagement telemetry). A URL QUERY PARAM, not a header:
// the cloud's ordinary access logs capture the request URL, so the marker is
// countable with zero cloud-side code. No separate tracking calls are ever
// made (wp.org policy) — this only annotates requests that happen anyway.
// Only appended when known; absent means "unknown", not an error.
if ( '' !== self::get_request_source() ) {
$api_url = add_query_arg( 'tl_source', self::get_request_source(), $api_url );
}
// Merge additional headers
$headers = array_merge($headers, $extra_headers);
// A request the HTTP layer is allowed to spend $timeout seconds on can still
// be killed by PHP's OWN max_execution_time, which is 30s on a great many
// hosts. When that happens there is no WP_Error and no response to inspect:
// PHP fatals mid-request, WordPress answers with its HTML "critical error"
// page, and a JSON client renders that markup as if it were content. Give
// PHP enough runway for the timeout we are about to ask for.
self::extend_time_limit( $timeout );
$args = [
'timeout' => $timeout,
'headers' => $headers,
];
// Apply filter to allow network admin or other functionality to modify request args
$args = apply_filters( 'templately_api_request_params', $args, $method, $api_url );
// Add body for POST requests
if (strtoupper($method) === 'POST') {
$args['body'] = is_array($body) ? json_encode($body) : $body;
}
// Make the appropriate request
if (strtoupper($method) === 'POST') {
$response = wp_remote_post($api_url, $args);
} else {
$response = wp_remote_get($api_url, $args);
}
// Check for verification header in the response
self::check_verification_header($response);
// Check for site disconnection in response body
self::check_site_disconnection($response);
return $response;
}
/**
* GET a Templately API endpoint and return the NORMALIZED result (spec 043).
*
* This is the REST twin of `Http::post()`. Prefer it over
* `make_api_get_request()`: that one hands back the raw `wp_remote_*` array and
* leaves every caller to invent its own "did this fail?" check — which is how
* the plugin ended up reading one server ten different ways.
*
* Side-effects (verification / disconnection) are already applied by
* `make_api_request()`, so the normalizer is told to skip them rather than
* repeat the work.
*
* @param string $endpoint API endpoint path (e.g. 'v2/import/info/pack/123').
* @param array $query_params Query parameters.
* @param array $extra_headers Additional headers.
* @param int $timeout Seconds.
* @param array $options Normalizer options (e.g. [ 'raw' => true ] for binary).
* @return \Templately\Utils\Response\RemoteResponse
*/
public static function api_get( $endpoint, $query_params = [], $extra_headers = [], $timeout = 30, $options = [] ) {
return ResponseNormalizer::normalize(
self::make_api_get_request( $endpoint, $query_params, $extra_headers, $timeout ),
array_merge( [ 'side_effects' => false ], $options )
);
}
/**
* POST to a Templately API endpoint and return the NORMALIZED result (spec 043).
*
* @param string $endpoint API endpoint path.
* @param array $body Request body.
* @param array $extra_headers Additional headers.
* @param int $timeout Seconds.
* @param array $options Normalizer options.
* @return \Templately\Utils\Response\RemoteResponse
*/
public static function api_post( $endpoint, $body = [], $extra_headers = [], $timeout = 30, $options = [] ) {
return ResponseNormalizer::normalize(
self::make_api_post_request( $endpoint, $body, $extra_headers, $timeout ),
array_merge( [ 'side_effects' => false ], $options )
);
}
/**
* Make a GET request to Templately API
*
* @param string $endpoint API endpoint path (e.g., 'v2/import/pack/123')
* @param array $query_params Query parameters as key-value pairs
* @param array $extra_headers Additional headers beyond the standard ones
* @param int $timeout Request timeout in seconds (default: 30)
* @return array|WP_Error Response array or WP_Error on failure
*/
public static function make_api_get_request($endpoint, $query_params = [], $extra_headers = [], $timeout = 30) {
$api_url = self::get_api_url($endpoint);
// Add query parameters if provided.
//
// `http_build_query()`, NOT `add_query_arg()`: the latter does not encode the values
// it is given (only the ones already on the URL, via its own `urlencode_deep`), so a
// value carrying `&` silently became two parameters and a value carrying `#` truncated
// the rest of the query into a fragment the server never sees. Every param here comes
// from somewhere a user can type — an image search term, a business description, a hex
// colour — so that is not a theoretical shape. It also flattens arrays as `k[0]=…`,
// which is what PHP on the other end parses back into an array.
if (!empty($query_params)) {
$separator = false === strpos($api_url, '?') ? '?' : '&';
$api_url .= $separator . http_build_query($query_params, '', '&', PHP_QUERY_RFC3986);
}
return self::make_api_request('GET', $api_url, [], $extra_headers, $timeout);
}
/**
* Make a POST request to Templately API
*
* @param string $endpoint API endpoint path (e.g., 'v2/feedback/store')
* @param array $body Request body data
* @param array $extra_headers Additional headers beyond the standard ones
* @param int $timeout Request timeout in seconds (default: 30)
* @return array|WP_Error Response array or WP_Error on failure
*/
public static function make_api_post_request($endpoint, $body = [], $extra_headers = [], $timeout = 30) {
$api_url = self::get_api_url($endpoint);
return self::make_api_request('POST', $api_url, $body, $extra_headers, $timeout);
}
/**
* Ensure PHP will not cut the process short before an HTTP call of $timeout
* seconds can finish.
*
* Only ever RAISES the limit, and only when the configured one is too small;
* a host that has already granted more keeps it. `max_execution_time` of 0
* means "no limit" (CLI, WP-Cron on some setups) and needs nothing. When the
* host has disabled `set_time_limit()`, `function_exists()` is false and we
* leave the request to take its chances rather than emit a warning.
*
* @param int $timeout Seconds the pending HTTP request may take.
* @return void
*/
private static function extend_time_limit( $timeout ) {
$limit = (int) ini_get( 'max_execution_time' );
if ( $limit <= 0 || ! function_exists( 'set_time_limit' ) ) {
return;
}
// The margin covers everything around the call that also runs on this
// clock: building the payload, and the response handling afterwards.
$needed = (int) $timeout + 30;
if ( $needed > $limit ) {
@set_time_limit( $needed ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- host may refuse; the request still proceeds.
}
}
/**
* Sanitize Helper
*
* @param mixed $value
* @param string $type
*
* @return bool|string
*/
public static function sanitize($value, $type = 'text') {
switch ($type) {
case 'boolean':
$sanitized_value = rest_sanitize_boolean($value);
break;
default:
$sanitized_value = sanitize_text_field($value);
break;
}
return $sanitized_value;
}
/**
* Escape a string for safe embedding inside a GraphQL or JSON string literal.
*
* GraphQL string escaping rules are identical to JSON string escaping (per the
* GraphQL spec), so wp_json_encode() is the authoritative escaper. We strip the
* outer quotes it adds and return only the escaped inner content, ready to be
* wrapped in your own quote pair.
*
* Handles pre-encoded JSON: when the caller has already run json_encode() +
* wp_slash() on a value (e.g. categories, dependencies in Items.php), the
* quotes are already escaped as \" and the string is ready to embed. Calling
* wp_json_encode() again would double-escape those backslashes. We detect this
* case by checking whether wp_unslash() produces valid JSON, and if so, return
* the value directly without further encoding.
*
* @param string $value Raw string or wp_slash(json_encode()) output.
* @return string Escaped string, safe to place between double quotes in GraphQL/JSON.
*/
public static function esc_json_string( $value ) {
$value = (string) $value;
// If wp_slash() was applied to a JSON string upstream, the quotes are
// already escaped (e.g. {\"key\":\"val\"}). Detect this by unslashing and
// checking for valid JSON — if it matches, the value is already suitable
// for embedding in a string literal; return it as-is to avoid doubling backslashes.
$unslashed = wp_unslash( $value );
if ( $unslashed !== $value ) {
$decoded = json_decode( $unslashed, true );
if ( json_last_error() === JSON_ERROR_NONE && null !== $decoded ) {
return $value;
}
}
$encoded = wp_json_encode( $value );
// wp_json_encode wraps the value in "...", strip those outer quotes.
return substr( $encoded, 1, -1 );
}
/**
* Flip the stored user's `is_verified` flag on.
*
* Extracted for spec 043 so the normalizer can apply the same side-effect
* when verification arrives in the response BODY (the connect mutation)
* rather than in the `X-Templately-Verified` header.
*
* @return array the (possibly updated) user option; empty when no user is stored.
*/
public static function mark_user_verified() {
$options = Options::get_instance();
$user = $options->get('user');
if (empty($user) || !is_array($user)) {
return [];
}
if (empty($user['is_verified'])) {
$user['is_verified'] = true;
$options->set('user', $user);
}
return $user;
}
/**
* Check for X-Templately-Verified header and update user verification status
*
* @param array|WP_Error $response The HTTP response array from wp_remote_get/wp_remote_post
* @return void
*/
public static function check_verification_header($response) {
// Only process if response is not a WP_Error and contains headers
if (is_wp_error($response)) {
return;
}
// Retrieve the X-Templately-Verified header
$verification_header = wp_remote_retrieve_header($response, 'X-Templately-Verified');
// Check if header exists and has a truthy value.
// An empty/absent header means "no change" — never "unverified" (043 D6).
if (!empty($verification_header) && filter_var($verification_header, FILTER_VALIDATE_BOOLEAN)) {
try {
$user = self::mark_user_verified();
if (!empty($user['is_verified'])){
if(!headers_sent()){
header( 'X-Templately-Verified: true' );
if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
self::log('User verification status already updated via X-Templately-Verified header');
}
}
return true;
}
} catch (\Exception $e) {
// Log error if debug logging is enabled
if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
self::log('Error updating user verification status: ' . $e->getMessage());
}
}
}
return false;
}
/**
* Check for site disconnection status in API response body
*
* Detects SiteNotConnected errors and updates user disconnection status.
* Sends X-Templately-Disconnected header for frontend detection.
*
*
* @param array|WP_Error|mixed $response The response object or body array
* @return bool True if site is disconnected, false otherwise
*/
public static function check_site_disconnection($response) {
if (is_wp_error($response)) {
return false;
}
$response_body = $response;
// If it's a raw WP response array with body, decode it
if (is_array($response) && isset($response['body']) && is_string($response['body'])) {
$response_body = json_decode(wp_remote_retrieve_body($response), true);
}
// Check if response body indicates site disconnection
if (!is_array($response_body)) {
return false;
}
$status = $response_body['status'] ?? null;
$status_text = $response_body['statusText'] ?? null;
// Check for SiteNotConnected error
if ($status === 'error' && $status_text === 'SiteNotConnected') {
try {
// Get current user data
$options = Options::get_instance();
$user = $options->get('user');
// Only update if user data exists
if (!empty($user) && is_array($user)) {
// Set disconnection flag
$user['is_disconnected'] = true;
// Save updated user data
$options->set('user', $user);
if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
self::log('Site disconnection detected: SiteNotConnected status');
}
}
// Send header for frontend detection
if (!headers_sent()) {
header('X-Templately-Disconnected: true');
}
return true;
} catch (\Exception $e) {
// Log error if debug logging is enabled
if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
self::log('Error updating site disconnection status: ' . $e->getMessage());
}
}
}
return false;
}
/**
* Clear site disconnection status
*
* Called after successful site migration to reset the disconnection flag.
*
* @return void
*/
public static function clear_site_disconnection() {
try {
$options = Options::get_instance();
$user = $options->get('user');
if (!empty($user) && is_array($user)) {
$user['site_url'] = base64_encode( home_url('/') );
$user['is_disconnected'] = false;
$options->set('user', $user);
if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
self::log('Site disconnection status cleared and URL updated.');
}
}
} catch (\Exception $e) {
if (defined('TEMPLATELY_DEBUG_LOG') && constant('TEMPLATELY_DEBUG_LOG')) {
self::log('Error clearing site disconnection status: ' . $e->getMessage());
}
}
}
/**
* API Error Formatter
*
* @param int $error_code
* @param mixed $error_message
* @param string $endpoint
* @param integer $status
* @param array $additional_data
* @return WP_Error
*/
public static function error($error_code, $error_message, $endpoint = '', $status = 500, $additional_data = []) {
// The status reaches WP's REST server as `data.status` and decides the HTTP
// code. A STRING there ("404") makes WP fall back to 500, so a caller that
// passed a numeric string got the wrong status on the wire.
$additional_data['status'] = (int) $status;
// An array message is field-level validation detail, not a sentence. Left in
// `message` it renders as "Array" to the user; routed to `fields` it is
// structured data the client can attach to the right input (FR-007).
if (is_array($error_message)) {
$additional_data['fields'] = array_merge(
isset($additional_data['fields']) && is_array($additional_data['fields']) ? $additional_data['fields'] : [],
$error_message
);
$error_message = __('Please correct the highlighted fields.', 'templately');
}
if (! empty($endpoint)) {
$additional_data['endpoint'] = $endpoint;
}
// Small-response browser padding is NOT added here any more (043 FR-008a).
// It used to staple 512 bytes into every error's data bag unconditionally,
// which put a junk field inside the response contract and padded bodies
// that were never small enough to need it. `RestEnvelope` now applies it
// once, centrally, only below the size threshold, and as a header — so the
// body stays exactly the shape the schema describes.
return new WP_Error($error_code, $error_message, $additional_data);
}
/**
* API Response Formatter
*
* @param mixed $data
* @return WP_REST_Response
*/
public static function success($data) {
return new WP_REST_Response($data, 200);
}
/**
* Normalize Favourites Data
*
* @param array $favourites
* @param array $_favourites
* @param boolean $undo
*
* @return array
*/
public function normalizeFavourites($favourites, $_favourites = [], $undo = false) {
if ($undo) {
$_favourites[$favourites['type']] = array_values(array_filter($_favourites[$favourites['type']], function ($item) use ($favourites) {
return $item != $favourites['id'];
}));
return $_favourites;
}
array_map(function ($item) use (&$_favourites) {
if (! is_null($item)) {
$item = (array) $item;
if (isset($_favourites[$item['type']])) {
$_favourites[$item['type']][] = $item['id'];
} else {
$_favourites[$item['type']] = [$item['id']];
}
}
return $_favourites;
}, $favourites);
return $_favourites;
}
public function normalizeReviews($favourites, $_favourites = [], $undo = false) {
array_map(function ($item) use (&$_favourites) {
if (! is_null($item)) {
$item = (array) $item;
if (!isset($_favourites[$item['type']])) {
$_favourites[$item['type']] = [];
}
$_favourites[$item['type']][$item['type_id']] = $item['rating'];
}
return $_favourites;
}, $favourites);
return $_favourites;
}
/**
* Trigger Error
*
* @param object $triggered_by
* @return void
*/
public static function trigger_error($triggered_by, $method = 'get_instance') {
$class = get_class($triggered_by);
$trace = debug_backtrace(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection
$file = $trace[0]['file'];
$line = $trace[0]['line'];
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
}
/**
* Write a line to Templately's dedicated log file (uploads/templately/log/),
* NOT the site-wide debug.log — see {@see \Templately\Utils\Log\LogFile}.
*
* @param mixed $log The data to log
* @param string $context Optional context for categorizing log entries
* @param string $level Optional log level (debug, info, warning, error)
* @return void
*/
public static function log($log, $context = '', $level = 'info') {
// Allow complete override of logging behavior
$override_result = apply_filters('templately_log_override', null, $log, $context, $level);
if ($override_result !== null) {
return;
}
// The default sink is WP_DEBUG_LOG-gated; additional sinks subscribed to
// the `templately_log` action see entries regardless (a capture module
// must not depend on the file gate). Skip the formatting work entirely
// when nobody would receive the entry.
$gate_open = defined('WP_DEBUG_LOG') && WP_DEBUG_LOG;
if (!$gate_open && !has_action('templately_log')) {
return;
}
// Format the log message
$formatted_message = self::format_log_message($log, $context, $level);
/**
* Fan-out: every subscribed module receives every entry (http-inspector
* capture, dev console, telemetry…). Listeners are independent sinks —
* this is how MULTIPLE loggers coexist without replacing each other.
*
* @param string $formatted_message The full formatted line.
* @param mixed $log The raw payload passed to log().
* @param string $context Context label.
* @param string $level debug|info|warning|error.
*/
do_action('templately_log', $formatted_message, $log, $context, $level);
if (!$gate_open) {
return;
}
/**
* The DEFAULT sink, replaceable/decoratable: a filter receives the
* current sink callable and may return a wrapper around it (decorator —
* filter priority defines wrap order) or a substitute. Core's default is
* the dedicated uploads log file (error_log fallback inside). Note:
* entries logged before a module boots (e.g. Modules_Manager discovery
* notices) necessarily use the core default.
*
* @param callable $sink function( string $formatted_line ): void
*/
$sink = apply_filters('templately_log_sink', [Log\LogFile::class, 'write']);
if (is_callable($sink)) {
$sink($formatted_message);
} else {
Log\LogFile::write($formatted_message);
}
}
/**
* Format log message with context and level
*
* @param mixed $log The data to log
* @param string $context Context for categorizing log entries
* @param string $level Log level
* @return string Formatted log message
*/
private static function format_log_message($log, $context = '', $level = 'info') {
// Convert arrays and objects to readable format
if (is_array($log) || is_object($log)) {
$log_content = print_r($log, true);
} else {
$log_content = (string) ($log ?: '');
}
// Build the formatted message
$timestamp = current_time('Y-m-d H:i:s');
$level_upper = strtoupper($level);
if (!empty($context)) {
return "[{$timestamp}] [{$level_upper}] [{$context}] {$log_content}";
} else {
return "[{$timestamp}] [{$level_upper}] {$log_content}";
}
}
public static function should_flush() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a read-only capability probe, not a state change.
$is_lightspeed = isset($_REQUEST['is_lightspeed']) ? sanitize_text_field(wp_unslash($_REQUEST['is_lightspeed'])) : '';
if ('true' === $is_lightspeed) {
return false;
}
// SERVER_SOFTWARE is not guaranteed to be set — some SAPIs (and CLI) omit
// it entirely, and passing null to strpos() is a fatal on PHP 8.1+.
$server_software = isset($_SERVER['SERVER_SOFTWARE']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_SOFTWARE'])) : '';
return (!defined('TEMPLATELY_IGNORE_FLUSH_ALL') || !TEMPLATELY_IGNORE_FLUSH_ALL) && strpos($server_software, 'LiteSpeed') === false;
}
public static function get_block_by_name($blocks, $search) {
$queue = $blocks;
while (!empty($queue)) {
$current_block = array_shift($queue);
if ($search === $current_block['blockName']) {
return $current_block;
}
if (isset($current_block['innerBlocks'])) {
// Add nested blocks to the end of the queue for processing
$queue = array_merge($queue, $current_block['innerBlocks']);
}
}
return false;
}
/**
* Only checks if user can install/activate plugins
*
* @param [type] $cap
* @param [type] ...$args
* @return void
*/
public static function current_user_can($cap, ...$args) {
$user = wp_get_current_user();
// Multisite super admin has all caps by definition, Unless specifically denied.
if (is_multisite() && is_super_admin($user->ID)) {
return true;
}
$caps = map_meta_cap($cap, $user->ID, ...$args);
switch ($cap) {
case 'install_plugins':
case 'upload_plugins':
$caps = ['install_plugins'];
break;
case 'install_themes':
case 'upload_themes':
$caps = ['install_themes'];
break;
case 'activate_plugins':
case 'deactivate_plugins':
case 'activate_plugin':
case 'deactivate_plugin':
$caps = ['activate_plugins'];
break;
default:
break;
}
// Maintain BC for the argument passed to the "user_has_cap" filter.
$args = array_merge(array($cap, $user->ID), $args);
/**
* See WP_User::has_cap() for description.
*/
$capabilities = apply_filters('user_has_cap', $user->allcaps, $caps, $args, $user);
// Everyone is allowed to exist.
$capabilities['exist'] = true;
// Nobody is allowed to do things they are not allowed to do.
unset($capabilities['do_not_allow']);
// Must have ALL requested caps.
foreach ((array) $caps as $cap) {
if (empty($capabilities[$cap])) {
return false;
}
}
return true;
}
/**
* Calculates the elapsed time and checks if it is close to the maximum execution time.
* Returns true if the script should exit to avoid exceeding the limit.
*
* @return bool True if the script should exit, false otherwise.
*/
public static function fsi_should_exit() {
if (!defined('TEMPLATELY_START_TIME')) {
return false;
}
$max_time = (int) ini_get('max_execution_time');
$elapsed = microtime(true) - TEMPLATELY_START_TIME;
if ($max_time <= 0) {
// The import request calls set_time_limit(0), which zeroes
// max_execution_time — but gateways (FPM, LiteSpeed, nginx proxies)
// still kill the request on THEIR clock, typically at 60s. With no
// PHP limit to lean on, budget against a wall-clock ceiling instead
// so runners keep chunking gracefully (and persisting their
// backup_attributes) instead of dying mid-loop to a 504.
$budget = (int) apply_filters('templately_fsi_request_time_budget', 25);
if ($budget > 0 && $elapsed >= $budget) {
return ['max_time' => $budget, 'elapsed' => $elapsed, 'delay' => 0];
}
return false;
}
$delay = max(5, $max_time * 20 / 100);
// Check if elapsed time is close to max execution time
if ($max_time - $elapsed <= $delay) {
return ['max_time' => $max_time, 'elapsed' => $elapsed, 'delay' => $delay];
}
return false;
}
/**
* Enable Elementor Container
* This function will enable the Elementor Container feature.
* Without this feature, some of the templates may not work properly.
*
* @return boolean
*/
public static function enable_elementor_container() {
if (class_exists('Elementor\Plugin')) {
$control_name = Plugin::instance()->experiments->get_feature_option_key('container');
if (get_option($control_name) !== 'active') {
update_option($control_name, 'active');
return true;
}
}
return false;
}
/**
* Undocumented function
*
* @param [type] $args
* @param [type] $defaults
* @return array
*/
/**
* Returns true when the current WordPress site is running on a private/loopback
* host where an external API server cannot push callbacks (localhost, *.local,
* *.test, RFC-1918 addresses). Used as a fallback when the remote API does not
* return an explicit is_local_site flag.
*/
public static function is_local_site(): bool {
$host = (string) parse_url( get_option( 'siteurl' ), PHP_URL_HOST );
// Strip port if present (e.g. "localhost:8888")
$host = (string) preg_replace( '/:\d+$/', '', $host );
if ( in_array( $host, [ 'localhost', '127.0.0.1', '::1' ], true ) ) {
return true;
}
// Common local/dev TLDs that no public DNS resolves (an external API server
// cannot push callbacks to them): .local/.test (mDNS/RFC-6761), .tst (the
// WPDeveloper sandbox), .localhost, .dev.
// `substr()` rather than `str_ends_with()`: the plugin advertises WordPress 5.0 /
// PHP 7.2, `str_ends_with()` is PHP 8.0+, and core only polyfills it from WP 5.9 —
// so the call fatals on a host inside our own advertised floor.
foreach ( [ '.local', '.test', '.tst', '.localhost' ] as $suffix ) {
if ( substr( $host, - strlen( $suffix ) ) === $suffix ) {
return true;
}
}
// RFC-1918 private ranges
if ( preg_match( '/^192\.168\./', $host )
|| preg_match( '/^10\./', $host )
|| preg_match( '/^172\.(1[6-9]|2[0-9]|3[01])\./', $host ) ) {
return true;
}
return false;
}
public static function recursive_wp_parse_args($args, $defaults) {
$args = (array) $args;
$defaults = (array) $defaults;
$r = $defaults;
foreach ($args as $key => $value) {
if (is_array($value) && isset($r[$key])) {
// also handle numeric array. if both $value and $r[ $key ] are numeric array. wp_is_numeric_array()
if (wp_is_numeric_array($value) && wp_is_numeric_array($r[$key])) {
foreach ($value as $k => $v) {
if (!in_array($v, $r[$key])) {
if (!isset($r[$key][$k])) {
$r[$key][$k] = $v;
} else {
$r[$key][] = $v;
}
}
}
} else {
$r[$key] = self::recursive_wp_parse_args($value, $r[$key]);
}
} else {
$r[$key] = $value;
}
}
return $r;
}
/**
* Creates the plugin's working directory under wp-uploads and blocks direct
* web access to it.
*
* Everything the importer needs on disk lands here: the extracted pack (its
* WXR, its template JSON, its attachments), the AI-generated page JSON, and
* the FSI logs. wp-uploads is web-served, so these paths are not private just
* because their session id is a uuid — the guards are what makes them
* unreadable, not the name.
*
* .htaccess covers Apache and is inherited by everything below this point;
* web.config covers IIS; index.php stops a directory listing on any server.
* nginx honours none of them, so an nginx site still needs a location rule —
* this raises the floor, it does not replace server configuration.
*
* @param string $dir Absolute path to create and protect.
*
* @return bool Whether the directory exists and is usable.
*/
public static function protect_directory( $dir ) {
if ( empty( $dir ) ) {
return false;
}
if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
return false;
}
$guards = [
'index.php' => " "# Templately working files — not for direct access.\n\n\tRequire all denied\n\n\n\tOrder allow,deny\n\tDeny from all\n\n",
'web.config' => "\n\n\t\n\t\t\n\t\t\t\n\t\t\n\t\n\n",
];
foreach ( $guards as $file => $contents ) {
$path = trailingslashit( $dir ) . $file;
// Never overwrite: a site owner may have relaxed these deliberately.
if ( ! file_exists( $path ) ) {
@file_put_contents( $path, $contents ); // phpcs:ignore
}
}
return true;
}
/**
* Absolute path to the plugin's protected working directory in wp-uploads.
*
* @param string $sub Optional subdirectory ('tmp', 'log', 'preview', ...).
*
* @return string Trailing-slashed path, or '' when uploads is unusable.
*/
public static function upload_dir( $sub = '' ) {
$upload_dir = wp_upload_dir();
if ( ! empty( $upload_dir['error'] ) || empty( $upload_dir['basedir'] ) ) {
return '';
}
$base = trailingslashit( $upload_dir['basedir'] ) . 'templately' . DIRECTORY_SEPARATOR;
// The guards go on the root so every subdirectory inherits them.
self::protect_directory( $base );
return '' === $sub ? $base : trailingslashit( $base . $sub );
}
}