PluginProbe
DesignSetGo / trunk
DesignSetGo vtrunk
2.7.4 2.7.3 2.7.1 2.7.2 2.7.0 2.6.3 2.6.2 2.6.1 2.6.0 2.5.1 2.4.1-test 2.5.0 2.4.0 2.3.0 2.2.0 2.1.2 2.1.1 2.1.0 trunk 1.0.1 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 68 releases
designsetgo / includes / patterns / class-loader.php

class-loader.php in DesignSetGo trunk, at includes/patterns/class-loader.php

417 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Patterns Loader Class
4 *
5 * @package DesignSetGo
6 * @since 1.0.0
7 */
8
9 namespace DesignSetGo\Patterns;
10
11 // Exit if accessed directly.
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Patterns Loader Class - Registers block patterns
18 *
19 * Performance optimizations:
20 * - Patterns only register on admin/REST/CLI requests (never on front-end page loads).
21 * - Full pattern data (including content) is cached in category-level transients to
22 * avoid repeated filesystem reads. Category-level splits keep each transient under
23 * safe size limits for MySQL max_allowed_packet and object cache item limits.
24 * - Smart invalidation via file modification time hashing detects file edits without
25 * requiring a plugin version bump.
26 * - Pattern context filtering via postTypes reduces the number of patterns sent
27 * to the editor in specific editing contexts.
28 */
29 class Loader {
30
31 /**
32 * Legacy transient name (kept for backward-compatible cleanup).
33 *
34 * @var string
35 */
36 const CACHE_TRANSIENT = 'dsgo_pattern_files';
37
38 /**
39 * Transient prefix for category-level pattern data caches.
40 *
41 * @var string
42 */
43 const CACHE_TRANSIENT_PREFIX = 'dsgo_pattern_data_';
44
45 /**
46 * Allowed pattern category directory names.
47 *
48 * @var array
49 */
50 const ALLOWED_CATEGORIES = array(
51 'homepage',
52 'header',
53 'footer',
54 'hero',
55 'features',
56 'pricing',
57 'testimonials',
58 'team',
59 'cta',
60 'content',
61 'faq',
62 'modal',
63 'gallery',
64 'contact',
65 'services',
66 'headings',
67 );
68
69 /**
70 * Constructor.
71 */
72 public function __construct() {
73 // Patterns are only used in the block editor. Skip registration entirely
74 // on front-end page loads to avoid unnecessary filesystem I/O and memory usage.
75 if ( ! self::is_editor_request() ) {
76 return;
77 }
78
79 add_action( 'init', array( $this, 'register_pattern_categories' ) );
80 add_action( 'init', array( $this, 'register_patterns' ) );
81 }
82
83 /**
84 * Check if the current request may need block patterns.
85 *
86 * Returns true for admin pages (classic editor, Site Editor), REST API
87 * requests (block editor fetches patterns via REST), and WP-CLI.
88 *
89 * @return bool
90 */
91 private static function is_editor_request() {
92 // Admin requests (editor page loads, Site Editor).
93 if ( is_admin() ) {
94 return true;
95 }
96
97 // REST API — constant may already be set by another plugin or mu-plugin.
98 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
99 return true;
100 }
101
102 // WP-CLI commands may need pattern data.
103 if ( defined( 'WP_CLI' ) && WP_CLI ) {
104 return true;
105 }
106
107 // Detect REST API requests by URL path. The REST_REQUEST constant is not
108 // defined until parse_request (which fires after this constructor), so
109 // URL-based detection is the reliable early check for REST requests.
110 if ( isset( $_SERVER['REQUEST_URI'] ) ) {
111 $rest_prefix = rest_get_url_prefix();
112 $request_uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) );
113 // Pretty permalinks: /wp-json/...
114 if ( false !== strpos( $request_uri, '/' . $rest_prefix . '/' ) ) {
115 return true;
116 }
117 // Plain permalinks: ?rest_route=/...
118 if ( false !== strpos( $request_uri, 'rest_route=' ) ) {
119 return true;
120 }
121 }
122
123 return false;
124 }
125
126 /**
127 * Register pattern categories.
128 */
129 public function register_pattern_categories() {
130 $categories = array(
131 'dsgo-homepage' => __( 'DesignSetGo: Homepage', 'designsetgo' ),
132 'dsgo-header' => __( 'DesignSetGo: Header', 'designsetgo' ),
133 'dsgo-footer' => __( 'DesignSetGo: Footer', 'designsetgo' ),
134 'dsgo-hero' => __( 'DesignSetGo: Hero', 'designsetgo' ),
135 'dsgo-features' => __( 'DesignSetGo: Features', 'designsetgo' ),
136 'dsgo-pricing' => __( 'DesignSetGo: Pricing', 'designsetgo' ),
137 'dsgo-testimonials' => __( 'DesignSetGo: Testimonials', 'designsetgo' ),
138 'dsgo-team' => __( 'DesignSetGo: Team', 'designsetgo' ),
139 'dsgo-cta' => __( 'DesignSetGo: Call to Action', 'designsetgo' ),
140 'dsgo-content' => __( 'DesignSetGo: Content', 'designsetgo' ),
141 'dsgo-faq' => __( 'DesignSetGo: FAQ', 'designsetgo' ),
142 'dsgo-modal' => __( 'DesignSetGo: Modals', 'designsetgo' ),
143 'dsgo-gallery' => __( 'DesignSetGo: Gallery', 'designsetgo' ),
144 'dsgo-contact' => __( 'DesignSetGo: Contact', 'designsetgo' ),
145 'dsgo-services' => __( 'DesignSetGo: Services', 'designsetgo' ),
146 'dsgo-headings' => __( 'DesignSetGo: Headings', 'designsetgo' ),
147 );
148
149 // Get pattern categories registry.
150 $registry = \WP_Block_Pattern_Categories_Registry::get_instance();
151
152 foreach ( $categories as $slug => $label ) {
153 // Only register if not already registered.
154 if ( ! $registry->is_registered( $slug ) ) {
155 register_block_pattern_category(
156 $slug,
157 array( 'label' => $label )
158 );
159 }
160 }
161 }
162
163 /**
164 * Get the post types mapping for pattern categories.
165 *
166 * Categories listed here will have their patterns restricted to the
167 * specified post types. Patterns in unlisted categories are available
168 * everywhere.
169 *
170 * @return array<string, string[]> Category slug => array of post type slugs.
171 */
172 private static function get_category_post_types() {
173 $map = array(
174 'homepage' => array( 'page' ),
175 );
176
177 /**
178 * Filters the category-to-postTypes mapping for pattern context filtering.
179 *
180 * @since 1.6.0
181 *
182 * @param array $map Category directory name => array of post type slugs.
183 */
184 return apply_filters( 'designsetgo_pattern_post_types_map', $map );
185 }
186
187 /**
188 * Compute a hash of file modification times for a set of files.
189 *
190 * Used to detect file edits without requiring a plugin version bump.
191 * The hash changes when any file in the category is added, removed, or modified.
192 *
193 * @param string[] $files Absolute file paths.
194 * @return string MD5 hash.
195 */
196 private static function compute_files_hash( $files ) {
197 $parts = array();
198 foreach ( $files as $file ) {
199 if ( ! file_exists( $file ) ) {
200 continue;
201 }
202 $mtime = filemtime( $file );
203 if ( false !== $mtime ) {
204 $parts[] = basename( $file ) . ':' . $mtime;
205 }
206 }
207 sort( $parts );
208 return md5( implode( '|', $parts ) );
209 }
210
211 /**
212 * Validate a relative pattern path before use.
213 *
214 * Ensures cached paths cannot be exploited for directory traversal
215 * or arbitrary file inclusion if the transient data is compromised.
216 *
217 * @param string $relative_path Relative path from the cache.
218 * @return bool True if the path is safe to use.
219 */
220 private static function is_valid_relative_path( $relative_path ) {
221 // Must be a non-empty string.
222 if ( ! is_string( $relative_path ) || '' === $relative_path ) {
223 return false;
224 }
225
226 // Must end with .php.
227 if ( '.php' !== substr( $relative_path, -4 ) ) {
228 return false;
229 }
230
231 // Must not contain directory traversal sequences.
232 if ( false !== strpos( $relative_path, '..' ) ) {
233 return false;
234 }
235
236 // Must not start with a path separator (absolute path).
237 if ( '/' === $relative_path[0] || '\\' === $relative_path[0] ) {
238 return false;
239 }
240
241 return true;
242 }
243
244 /**
245 * Get cached pattern data for a single category.
246 *
247 * Returns an associative array of slug => pattern data arrays. On cache miss,
248 * scans the category directory, requires each file, validates, and caches.
249 *
250 * @param string $category Category directory name (must be in ALLOWED_CATEGORIES).
251 * @return array<string, array> Pattern slug => pattern data array.
252 */
253 private function get_category_patterns( $category ) {
254 $patterns_dir = DESIGNSETGO_PATH . 'patterns/';
255 $category_dir = $patterns_dir . $category . '/';
256 $transient_key = self::CACHE_TRANSIENT_PREFIX . $category;
257
258 // Skip cache in debug mode so new/removed patterns are picked up immediately.
259 $cache_enabled = ! defined( 'WP_DEBUG' ) || ! WP_DEBUG;
260
261 /**
262 * Filters whether pattern caching is enabled.
263 *
264 * By default, caching is disabled when WP_DEBUG is true.
265 *
266 * @since 2.0.1
267 *
268 * @param bool $cache_enabled Whether caching is enabled.
269 */
270 $cache_enabled = apply_filters( 'designsetgo_pattern_cache_enabled', $cache_enabled );
271
272 if ( $cache_enabled ) {
273 $cached = get_transient( $transient_key );
274
275 if (
276 is_array( $cached )
277 && isset( $cached['version'], $cached['hash'] )
278 && DESIGNSETGO_VERSION === $cached['version']
279 ) {
280 $patterns_data = null;
281 if ( isset( $cached['compressed'] ) && is_string( $cached['compressed'] ) ) {
282 $raw = base64_decode( $cached['compressed'], true );
283 // Validate zlib header: CMF byte 0x78 (deflate, 32 KB window) + header checksum ((CMF*256+FLG)%31===0).
284 if ( false !== $raw && strlen( $raw ) >= 2 && 0x78 === ord( $raw[0] ) && 0 === ( ( ord( $raw[0] ) * 256 + ord( $raw[1] ) ) % 31 ) ) {
285 $decompressed = @gzuncompress( $raw, 10 * 1024 * 1024 ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Forbidden, WordPress.PHP.NoSilencedErrors.Discouraged -- gzuncompress() emits E_WARNING on corrupted data; no exception-based alternative exists. 10 MiB cap guards against a zip-bomb in a tampered transient.
286 if ( false !== $decompressed ) {
287 $patterns_data = json_decode( $decompressed, true );
288 }
289 }
290 }
291
292 if ( is_array( $patterns_data ) ) {
293 // Verify file hash still matches (catches manual file edits).
294 if ( is_dir( $category_dir ) ) {
295 $files = glob( $category_dir . '*.php' );
296 if ( is_array( $files ) && self::compute_files_hash( $files ) === $cached['hash'] ) {
297 return $patterns_data;
298 }
299 } elseif ( empty( $patterns_data ) ) {
300 // Directory doesn't exist and cache is empty — still valid.
301 return $patterns_data;
302 }
303 }
304 }
305 }
306
307 // Cache miss — scan directory and require each file.
308 $patterns = array();
309
310 if ( ! is_dir( $category_dir ) ) {
311 return $patterns;
312 }
313
314 $files = glob( $category_dir . '*.php' );
315
316 // glob() returns false on error, empty array when no matches.
317 if ( false === $files || empty( $files ) ) {
318 if ( false === $files ) {
319 wp_trigger_error( __METHOD__, sprintf( 'DesignSetGo: glob() failed for pattern directory: %s', $category_dir ), E_USER_NOTICE );
320 }
321 return $patterns;
322 }
323
324 $real_dir = realpath( $patterns_dir );
325 if ( ! $real_dir ) {
326 return $patterns;
327 }
328 $real_dir = rtrim( $real_dir, '/' ) . '/';
329
330 foreach ( $files as $file ) {
331 $relative_path = $category . '/' . basename( $file );
332
333 // Validate relative path structure.
334 if ( ! self::is_valid_relative_path( $relative_path ) ) {
335 wp_trigger_error( __METHOD__, sprintf( 'DesignSetGo: Skipped invalid relative pattern path: %s', $relative_path ), E_USER_NOTICE );
336 continue;
337 }
338
339 // Security: Verify resolved file is within expected directory.
340 $real_file = realpath( $file );
341 if ( ! $real_file || 0 !== strpos( $real_file, $real_dir ) ) {
342 wp_trigger_error( __METHOD__, sprintf( 'DesignSetGo: Skipped pattern file outside allowed directory: %s', $file ), E_USER_NOTICE );
343 continue;
344 }
345
346 // Load pattern file.
347 $pattern = require $real_file; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- $real_file is validated via realpath() and directory-traversal check above
348
349 // Validate pattern structure.
350 if ( is_array( $pattern ) && isset( $pattern['content'] ) ) {
351 $slug = 'designsetgo/' . sanitize_key( $category ) . '/' . sanitize_key( basename( $file, '.php' ) );
352 $patterns[ $slug ] = $pattern;
353 }
354 }
355
356 /** This filter is documented in includes/patterns/class-loader.php */
357 if ( apply_filters( 'designsetgo_pattern_cache_enabled', ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) ) {
358 /** This filter is documented in includes/patterns/class-loader.php */
359 $cache_duration = (int) apply_filters( 'designsetgo_pattern_cache_duration', DAY_IN_SECONDS );
360
361 set_transient(
362 $transient_key,
363 array(
364 'version' => DESIGNSETGO_VERSION,
365 'hash' => self::compute_files_hash( $files ),
366 'compressed' => base64_encode( gzcompress( wp_json_encode( $patterns ) ) ),
367 ),
368 $cache_duration
369 );
370 }
371
372 return $patterns;
373 }
374
375 /**
376 * Register all patterns.
377 */
378 public function register_patterns() {
379 $post_types_map = self::get_category_post_types();
380
381 foreach ( self::ALLOWED_CATEGORIES as $category ) {
382 // Validate category is in the allowed list (defense-in-depth).
383 $patterns = $this->get_category_patterns( $category );
384
385 foreach ( $patterns as $slug => $pattern ) {
386 // Inject postTypes if the category has a restriction and the
387 // pattern doesn't already declare its own.
388 if ( isset( $post_types_map[ $category ] ) && ! isset( $pattern['postTypes'] ) ) {
389 $pattern['postTypes'] = $post_types_map[ $category ];
390 }
391
392 // Replace placeholder tokens with local image URLs.
393 if ( isset( $pattern['content'] ) ) {
394 $pattern['content'] = designsetgo_replace_pattern_placeholders( $pattern['content'] );
395 }
396
397 register_block_pattern( $slug, $pattern );
398 }
399 }
400 }
401
402 /**
403 * Clear all pattern caches.
404 *
405 * Should be called on plugin activation to ensure a fresh scan.
406 */
407 public static function clear_cache() {
408 // Delete legacy transient.
409 delete_transient( self::CACHE_TRANSIENT );
410
411 // Delete all category-level transients.
412 foreach ( self::ALLOWED_CATEGORIES as $category ) {
413 delete_transient( self::CACHE_TRANSIENT_PREFIX . $category );
414 }
415 }
416 }
417