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
templately / modules / block-patterns / PatternSync.php

PatternSync.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/block-patterns/PatternSync.php

1,207 lines 41.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Modules\BlockPatterns;
4
5 use Templately\Utils\Base;
6 use Templately\Utils\Database;
7 use Templately\Utils\Helper;
8 use Templately\Utils\Http;
9 use Templately\Utils\Options;
10
11 /**
12 * Catalog sync: plan-keyed list cache (transient freshness signal + shadow
13 * option for stale-while-revalidate) and inert content files for lazy
14 * filePath registration. All remote work happens in cron events — an
15 * editor-facing read NEVER fetches inline (spec 051 FR-001; research D4/D5).
16 */
17 class PatternSync extends Base {
18
19 const EVENT_SYNC_LIST = 'templately_block_patterns_sync_list';
20 const EVENT_SYNC_CONTENT = 'templately_block_patterns_sync_content';
21
22 const LIST_KEY_PREFIX = 'block_patterns_';
23 const SHADOW_KEY_PREFIX = '_templately_block_patterns_shadow_';
24
25 /**
26 * Which user's connection this site syncs with.
27 *
28 * Needed because `Options` resolves the API key through the CURRENT user
29 * (`Options::user_id()` → `get_current_user_id()` for a non-global login), and
30 * WP-Cron runs with no user at all. So on a locally-connected site the cron
31 * sync read an empty key, `plan_key()` returned null, and `sync_list()`
32 * returned at its first line — silently, no fetch, no log, forever. Observed
33 * live: the event stayed scheduled, fired in 0.04s, and the inserter stayed
34 * empty while an admin page load kept rescheduling it.
35 */
36 const OWNER_OPTION = '_templately_block_patterns_owner';
37
38 /** Guards against N editor loads each starting their own catalog fetch. */
39 const LOCK_KEY = 'block_patterns_sync_lock';
40 const LOCK_TTL = 5 * MINUTE_IN_SECONDS;
41
42 /**
43 * Ids the current user has seen in a LIVE SEARCH, per user.
44 *
45 * `block-patterns/content` uses the cached catalog as its allow-list, which is
46 * exactly where plan gating lives (FR-003) — the native inserter has no
47 * insert-time hook, so an item that is not in the cache must not be fetchable.
48 * A live-searched item is by definition not in that cache, so it needs its own
49 * allow-list: short-lived, per user, and populated only by a search that has
50 * ALREADY applied the same plan filter.
51 */
52 const SEARCHED_KEY_PREFIX = 'block_patterns_searched_';
53 const SEARCHED_TTL = 30 * MINUTE_IN_SECONDS;
54 const SEARCHED_MAX = 300;
55
56 /**
57 * Plan cache keys, ordered LOWEST entitlement first. The order is load-bearing:
58 * `get_list()` walks it downwards to borrow a lower tier's cache when the
59 * current tier has never synced (see lower_tier_list()).
60 */
61 const PLAN_KEYS = [ 'free', 'starter', 'pro' ];
62 const CONTENT_BATCH_SIZE = 10;
63 const RESYNC_INTERVAL = 12 * HOUR_IN_SECONDS;
64
65 /** Hard stop on pagination, so a bad total_page cannot loop forever. */
66 const MAX_PAGES = 10;
67
68 /**
69 * How many sections to carry per template type (`templately_block_patterns_per_type`).
70 *
71 * The catalog is composed BY TYPE rather than as one popularity-ranked run,
72 * because the inserter browses by category: a flat top-N fills whichever types
73 * happen to be popular and leaves the rest of the sidebar empty. Ten per type
74 * gives every category something to show.
75 */
76 const DEFAULT_PER_TYPE = 20;
77
78 /**
79 * The two full pages the catalog always carries: newest, and most downloaded.
80 *
81 * Page templates feed the native new-page chooser, which shows EVERY
82 * page-kind pattern it is offered — so this stays deliberately tiny. Two
83 * curated entries is a chooser someone reads; a per-type fan-out is a wall.
84 */
85 const FEATURED_PAGE_SORTS = [ 'latest', 'download' ];
86
87 /**
88 * Runaway cap only — no longer the thing that decides catalog size.
89 *
90 * It used to be 100 because every registered pattern was inlined into every
91 * editor page load (~87KB of `__experimentalAdditionalBlockPatterns`). Since
92 * registration moved to `init` nothing inlines them, and the cost is one
93 * cached REST response the editor fetches only when it wants patterns. So the
94 * composition below decides the size and this is just a backstop against a
95 * cloud response nobody expected.
96 */
97 const DEFAULT_CEILING = 1000;
98
99 /** Template-type axis cache. Changes rarely; a failed read falls back, never empties. */
100 const TYPES_KEY = 'block_patterns_types';
101 const TYPES_TTL = 24 * HOUR_IN_SECONDS;
102
103 /**
104 * Seconds one composition run may spend fetching (`templately_block_patterns_sync_budget`).
105 *
106 * Ten, against PHP's default 30s `max_execution_time`, because this can run
107 * inside a request the editor makes and the run still has to publish, write
108 * an option and return afterwards.
109 */
110 const SYNC_BUDGET = 10.0;
111
112 /** Where a partly-composed catalog parks between runs. Autoloaded off. */
113 const PROGRESS_KEY_PREFIX = '_templately_block_patterns_progress_';
114
115 /**
116 * Plan cache key for the connected account, or null when not connected.
117 *
118 * The cloud returns PRODUCT names, not tiers — a live account reported
119 * `lifetime-five-hundred-site`. Matching against an allow-list of tier names
120 * therefore downgraded every paying customer to the free catalog. Follow the
121 * plugin's own convention instead (see `Admin::is_free_user`): `free` is
122 * free, anything else is paid.
123 */
124 public function plan_key(): ?string {
125 $api_key = Options::get_instance()->get( 'api_key' );
126 if ( empty( $api_key ) ) {
127 return null;
128 }
129
130 $user = Options::get_instance()->get( 'user', [] );
131 $plan = is_array( $user ) && ! empty( $user['plan'] ) ? strtolower( trim( (string) $user['plan'] ) ) : 'free';
132
133 if ( '' === $plan || 'free' === $plan ) {
134 return 'free';
135 }
136
137 return 'starter' === $plan ? 'starter' : 'pro';
138 }
139
140 /**
141 * Record whose connection this site syncs with. Called from contexts that
142 * HAVE a user (admin_init, the connect hook, the editor's sync request), so
143 * the userless cron run has someone to borrow.
144 */
145 public function remember_owner(): void {
146 $user_id = get_current_user_id();
147 if ( $user_id < 1 ) {
148 return;
149 }
150 if ( empty( Options::get_instance()->get( 'api_key', '', $user_id ) ) ) {
151 return;
152 }
153 if ( (int) get_option( self::OWNER_OPTION ) === $user_id ) {
154 return;
155 }
156
157 update_option( self::OWNER_OPTION, $user_id, false );
158 }
159
160 /**
161 * The user whose connection to sync with, or 0 when there is none.
162 *
163 * Global login first: that option IS the site-wide answer, and it is what
164 * `Options::user_id()` itself falls back to. Then the remembered owner, whose
165 * key is re-checked — a user can disconnect or be deleted without anything
166 * clearing this option.
167 */
168 public function owner_id(): int {
169 $global = (int) get_option( '_templately_global_login', 0 );
170 if ( $global > 0 && ! empty( Options::get_instance()->get( 'api_key', '', $global ) ) ) {
171 return $global;
172 }
173
174 $owner = (int) get_option( self::OWNER_OPTION, 0 );
175 if ( $owner > 0 && ! empty( Options::get_instance()->get( 'api_key', '', $owner ) ) ) {
176 return $owner;
177 }
178
179 return 0;
180 }
181
182 /**
183 * Adopt the owner's identity when the current context has no connection.
184 *
185 * Returns the user id to restore, or null when nothing was changed. ONLY for
186 * the sync entry points: `Http` reads the key through the current user too,
187 * so passing an id down to `plan_key()` alone would fix the gate and still
188 * send an unauthenticated fetch.
189 */
190 private function assume_owner(): ?int {
191 if ( null !== $this->plan_key() ) {
192 return null; // Already running as someone connected.
193 }
194
195 $owner = $this->owner_id();
196 if ( $owner < 1 ) {
197 return null;
198 }
199
200 $previous = get_current_user_id();
201 wp_set_current_user( $owner );
202
203 return $previous;
204 }
205
206 private function restore_user( ?int $previous ): void {
207 if ( null !== $previous ) {
208 wp_set_current_user( $previous );
209 }
210 }
211
212 /**
213 * Sync inline when the cache is missing or stale, unless another request is
214 * already doing it. Safe to call from a user-facing request — it is what the
215 * editor calls on load so the library does not depend on cron firing.
216 *
217 * @return array{status:string, count:int, plan_key:?string}
218 */
219 public function sync_if_stale(): array {
220 $this->remember_owner();
221
222 $previous = $this->assume_owner();
223
224 try {
225 $plan_key = $this->plan_key();
226 if ( null === $plan_key ) {
227 return [ 'status' => 'not_connected', 'count' => 0, 'plan_key' => null ];
228 }
229
230 // The transient IS the freshness signal (it expires at RESYNC_INTERVAL);
231 // the shadow option outlives it deliberately, so read the transient here
232 // rather than get_list(), which would report a stale shadow as a hit.
233 $fresh = Database::get_transient( self::LIST_KEY_PREFIX . $plan_key );
234
235 // A PARKED COMPOSITION IS NOT FRESH, however recently it was written.
236 // Each batch publishes what it has, so the transient exists and looks
237 // current while most of the catalog is still unfetched — reporting that
238 // as fresh would strand the remaining types until cron happened to
239 // fire, which is the single point of failure this endpoint exists to
240 // remove. The lock below still collapses concurrent editors onto one.
241 $parked = 0 !== $this->composition_progress( $plan_key )['cursor'];
242
243 if ( ! $parked && is_array( $fresh ) && ! empty( $fresh['items'] ) ) {
244 return [ 'status' => 'fresh', 'count' => count( $fresh['items'] ), 'plan_key' => $plan_key ];
245 }
246
247 if ( Database::get_transient( self::LOCK_KEY ) ) {
248 return [ 'status' => 'syncing', 'count' => 0, 'plan_key' => $plan_key ];
249 }
250
251 Database::set_transient( self::LOCK_KEY, time(), self::LOCK_TTL );
252
253 try {
254 $this->sync_list();
255 } finally {
256 Database::delete_transient( self::LOCK_KEY );
257 }
258
259 $list = $this->get_list();
260
261 return [
262 'status' => is_array( $list ) && ! empty( $list['items'] ) ? 'synced' : 'failed',
263 'count' => is_array( $list ) ? count( $list['items'] ) : 0,
264 'plan_key' => $plan_key,
265 ];
266 } finally {
267 $this->restore_user( $previous );
268 }
269 }
270
271 /**
272 * Fetch + cache the curated list for the current plan.
273 *
274 * Runs in cron AND, via {@see sync_if_stale()}, in the editor's own request.
275 */
276 public function sync_list(): void {
277 $previous = $this->assume_owner();
278
279 try {
280 $this->do_sync_list();
281 } finally {
282 $this->restore_user( $previous );
283 }
284 }
285
286 private function do_sync_list(): void {
287 $plan_key = $this->plan_key();
288 if ( null === $plan_key ) {
289 return; // v1: connected sites only (spec 051 Clarifications).
290 }
291
292 // Engagement marker: sync traffic must be separable from user imports
293 // in the cloud's access logs (spec 051 FR-010 / spec 006 FR-016).
294 add_filter( 'templately_request_source', [ $this, 'source_marker' ] );
295
296 // TWO endpoints, and they answer different questions. The blocks endpoint
297 // returns only sections, so querying it alone leaves the native new-page
298 // chooser with nothing; the pages endpoint feeds only that chooser.
299 //
300 // The composition is by TEMPLATE TYPE, not one popularity-ranked run. A
301 // flat top-N fills whichever types happen to be popular and leaves the
302 // rest of the inserter's category sidebar empty, which is the thing that
303 // made a 100-item catalog feel small — not the count itself.
304 //
305 // IT IS ALSO TIME-BOXED AND RESUMABLE, and that is not optional. Composing
306 // by type means one cloud request PER TYPE — measured on dev, 22 types plus
307 // the two featured pages and the axis query took 49 SECONDS end to end, at
308 // roughly 2s a request. `sync_if_stale()` runs this inside a request the
309 // editor makes, and PHP's default `max_execution_time` is 30s, so a
310 // single-shot composition would be killed halfway on a stock host and
311 // leave nothing behind. Each run therefore does as many types as fit in
312 // the budget, PUBLISHES what it has, and schedules the rest.
313 //
314 // Publishing every batch (rather than only the complete set) is what makes
315 // a half-finished catalog harmless: the inserter fills in over the next few
316 // loads instead of showing nothing until the whole composition lands.
317 $progress = $this->composition_progress( $plan_key );
318
319 $items = $progress['items'];
320
321 if ( 0 === $progress['cursor'] ) {
322 $items = array_merge( $items, $this->fetch_featured_pages( $plan_key ) );
323 }
324
325 $types = $this->section_template_types();
326 $per_type = (int) apply_filters( 'templately_block_patterns_per_type', self::DEFAULT_PER_TYPE );
327 $deadline = microtime( true ) + (float) apply_filters( 'templately_block_patterns_sync_budget', self::SYNC_BUDGET );
328 $cursor = $progress['cursor'];
329
330 if ( empty( $types ) ) {
331 // A failed axis query must not empty the catalog. Fall back to the flat
332 // popularity run this replaced, sized to roughly what the composition
333 // would have produced, and treat it as complete.
334 Helper::log( 'block-patterns: no template types resolved — falling back to a flat popular fetch.' );
335
336 $items = array_merge( $items, $this->fetch_type( 'items', 'section', max( 1, $per_type * 10 ), $plan_key ) );
337 $cursor = 0;
338 } else {
339 while ( $cursor < count( $types ) ) {
340 $items = array_merge(
341 $items,
342 $this->fetch_type( 'items', 'section', $per_type, $plan_key, [
343 'template_type_id' => (int) $types[ $cursor ]['id'],
344 ] )
345 );
346
347 $cursor++;
348
349 // Checked AFTER a type completes, never mid-type: a partially
350 // fetched type would be published as if it were all that exists.
351 if ( microtime( true ) >= $deadline ) {
352 break;
353 }
354 }
355
356 $cursor = $cursor >= count( $types ) ? 0 : $cursor;
357 }
358
359 remove_filter( 'templately_request_source', [ $this, 'source_marker' ] );
360
361 if ( empty( $items ) ) {
362 return; // FR-011: failed sync degrades to the existing cache, never errors.
363 }
364
365 // One design can be returned by more than one query — the newest full page
366 // may also be the most downloaded one, and a section can carry two types.
367 $items = $this->unique_by_id( $items );
368
369 $this->save_composition_progress( $plan_key, $cursor, $items );
370
371 $ceiling = (int) apply_filters( 'templately_block_patterns_ceiling', self::DEFAULT_CEILING );
372
373 if ( $ceiling > 0 && count( $items ) > $ceiling ) {
374 // Never truncate silently: a catalog that quietly stops at the cap
375 // reads as "this is everything the cloud has" when it is not.
376 Helper::log( sprintf(
377 'block-patterns: composed %d designs, ceiling %d — %d dropped.',
378 count( $items ),
379 $ceiling,
380 count( $items ) - $ceiling
381 ) );
382
383 $items = $this->trim_evenly( $items, $ceiling );
384 }
385
386 $list = [
387 'items' => $items,
388 'fetched_at' => time(),
389 'plan_key' => $plan_key,
390 ];
391
392 Database::set_transient( self::LIST_KEY_PREFIX . $plan_key, $list, self::RESYNC_INTERVAL );
393 update_option( self::SHADOW_KEY_PREFIX . $plan_key, $list, false );
394
395 // Only prefetch markup when patterns register EAGERLY. In lazy mode the
396 // content is fetched the moment someone inserts a pattern, so warming all
397 // ~100 files would be ~6.7MB of downloads and disk for markup that mostly
398 // never gets used.
399 if ( ! PatternRegistrar::lazy_mode() && $this->missing_content_ids() ) {
400 $this->schedule_once( self::EVENT_SYNC_CONTENT );
401 }
402 }
403
404 /**
405 * Live catalog search — the one path that is allowed to hit the cloud in
406 * response to something the user typed.
407 *
408 * Deliberately NOT cached in the plan-keyed list: this is a transient answer
409 * to a query, not the curated set the inserter registers. What it does write
410 * is the per-user allow-list, so the ids it returned become fetchable by
411 * `block-patterns/content` — plan gating is applied HERE, at the same
412 * boundary, using the same rule as the sync (a free plan never sees a pro
413 * item).
414 *
415 * @return array<int, array> Catalog Items, already plan-filtered.
416 */
417 public function search( string $term, int $limit = 24 ): array {
418 $term = trim( $term );
419 if ( '' === $term ) {
420 return [];
421 }
422
423 $previous = $this->assume_owner();
424
425 try {
426 $plan_key = $this->plan_key();
427 if ( null === $plan_key ) {
428 return [];
429 }
430
431 add_filter( 'templately_request_source', [ $this, 'source_marker' ] );
432
433 $total = 1;
434 $items = array_merge(
435 $this->fetch_page( 'items', 'section', $limit, $plan_key, 1, $total, $term ),
436 $this->fetch_page( 'pages', 'page', $limit, $plan_key, 1, $total, $term )
437 );
438
439 remove_filter( 'templately_request_source', [ $this, 'source_marker' ] );
440
441 $items = array_slice( $items, 0, $limit );
442
443 $this->remember_searched( wp_list_pluck( $items, 'id' ) );
444
445 return $items;
446 } finally {
447 $this->restore_user( $previous );
448 }
449 }
450
451 /**
452 * Add ids to the acting user's search allow-list.
453 *
454 * Newest first and capped: the list only has to cover ids the user can still
455 * plausibly click, and an uncapped one would grow with every keystroke's
456 * results until it expired.
457 *
458 * @param int[] $ids
459 */
460 private function remember_searched( array $ids ): void {
461 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
462 if ( empty( $ids ) ) {
463 return;
464 }
465
466 $key = self::SEARCHED_KEY_PREFIX . get_current_user_id();
467 $existing = Database::get_transient( $key );
468 $existing = is_array( $existing ) ? array_map( 'absint', $existing ) : [];
469
470 $merged = array_slice( array_values( array_unique( array_merge( $ids, $existing ) ) ), 0, self::SEARCHED_MAX );
471
472 Database::set_transient( $key, $merged, self::SEARCHED_TTL );
473 }
474
475 /**
476 * Whether this user may fetch the markup for an id that is not in the cached
477 * catalog — i.e. one their own search returned.
478 */
479 public function was_searched( int $id ): bool {
480 $stored = Database::get_transient( self::SEARCHED_KEY_PREFIX . get_current_user_id() );
481
482 return is_array( $stored ) && in_array( absint( $id ), array_map( 'absint', $stored ), true );
483 }
484
485 public function source_marker(): string {
486 return 'pattern-sync';
487 }
488
489 /**
490 * Read the cached list: fresh transient first, then the shadow copy, then a
491 * LOWER tier's cache (and schedule an async refresh). Never fetches.
492 * Null = nothing has ever synced for this account at any tier.
493 */
494 public function get_list(): ?array {
495 $plan_key = $this->plan_key();
496 if ( null === $plan_key ) {
497 return null;
498 }
499
500 $list = Database::get_transient( self::LIST_KEY_PREFIX . $plan_key );
501 if ( is_array( $list ) && isset( $list['items'] ) ) {
502 return $list;
503 }
504
505 $shadow = get_option( self::SHADOW_KEY_PREFIX . $plan_key );
506 if ( is_array( $shadow ) && isset( $shadow['items'] ) ) {
507 $this->schedule_once( self::EVENT_SYNC_LIST );
508
509 return $shadow;
510 }
511
512 return $this->lower_tier_list( $plan_key );
513 }
514
515 /**
516 * The best cache from a LOWER plan tier, or null when there is none.
517 *
518 * A plan change switches the cache key to one that has never synced, and the
519 * refresh only ever happens in cron — so between the two the account had NO
520 * cache at its own key and the entire library vanished from the inserter with
521 * no error and no log. That is up to a full resync interval on a healthy site
522 * and unbounded on one where cron does not fire (a stalled loopback request is
523 * enough). Borrowing the tier below closes that window: entitlement grows
524 * monotonically, so anything cached for a lower tier is by definition usable by
525 * a higher one, while a refresh for the real tier is scheduled.
526 *
527 * DOWNWARD ONLY. The reverse would hand a free account the paid catalog and
528 * defeat the plan gate that deliberately lives at this cache boundary (FR-003)
529 * — the native inserter has no insert-time hook to catch it later.
530 *
531 * The borrowed list is capped at the ceiling: it was written under another key
532 * and may predate the current limit (a real site carried a 3430-item free
533 * shadow from an ad-hoc sync), and registering that many patterns is exactly
534 * the editor-payload blowup the ceiling exists to prevent.
535 */
536 private function lower_tier_list( string $plan_key ): ?array {
537 $rank = array_search( $plan_key, self::PLAN_KEYS, true );
538 if ( ! is_int( $rank ) || $rank < 1 ) {
539 return null; // unknown key, or already the lowest tier — nothing below.
540 }
541
542 // Nearest tier first: closer to the real entitlement than anything under it.
543 $lower_keys = array_reverse( array_slice( self::PLAN_KEYS, 0, $rank ) );
544
545 foreach ( $lower_keys as $lower ) {
546 $cached = Database::get_transient( self::LIST_KEY_PREFIX . $lower );
547 if ( ! is_array( $cached ) || ! isset( $cached['items'] ) ) {
548 $cached = get_option( self::SHADOW_KEY_PREFIX . $lower );
549 }
550 if ( ! is_array( $cached ) || ! isset( $cached['items'] ) ) {
551 continue;
552 }
553
554 $this->schedule_once( self::EVENT_SYNC_LIST );
555
556 $ceiling = (int) apply_filters( 'templately_block_patterns_ceiling', 100 );
557
558 $cached['items'] = array_slice( (array) $cached['items'], 0, max( 1, $ceiling ) );
559 $cached['borrowed_from'] = $lower;
560
561 return $cached;
562 }
563
564 return null;
565 }
566
567 /**
568 * Fetch content for up to CONTENT_BATCH_SIZE items missing a file, remove
569 * orphaned files, chain another batch if items remain. Cron-context only.
570 */
571 public function sync_content_batch(): void {
572 $previous = $this->assume_owner();
573
574 try {
575 $this->do_sync_content_batch();
576 } finally {
577 $this->restore_user( $previous );
578 }
579 }
580
581 private function do_sync_content_batch(): void {
582 $list = $this->get_list();
583 if ( null === $list ) {
584 return;
585 }
586
587 // Lazy patterns fetch their own markup on insert; prefetching it here
588 // would download the whole catalog to serve the few that get used.
589 if ( PatternRegistrar::lazy_mode() ) {
590 return;
591 }
592
593 $this->remove_orphan_files( wp_list_pluck( $list['items'], 'id' ) );
594
595 add_filter( 'templately_request_source', [ $this, 'source_marker' ] );
596
597 $fetched = 0;
598 foreach ( $this->missing_content_ids() as $id ) {
599 if ( $fetched >= self::CONTENT_BATCH_SIZE ) {
600 $this->schedule_once( self::EVENT_SYNC_CONTENT );
601 break;
602 }
603 $content = $this->fetch_content( $id );
604 if ( is_string( $content ) && '' !== $content ) {
605 $this->write_content_file( $id, $content );
606 }
607 $fetched++;
608 }
609
610 remove_filter( 'templately_request_source', [ $this, 'source_marker' ] );
611 }
612
613 /**
614 * Pattern markup for one item: cached file first, otherwise fetched now and
615 * cached for next time.
616 *
617 * This is the read path for the lazy-pattern block — the only moment a
618 * pattern's real content is needed is when someone actually inserts it, so
619 * a cache miss here is normal rather than exceptional.
620 */
621 public function get_content( int $id ): ?string {
622 $path = $this->content_path( $id );
623 if ( file_exists( $path ) ) {
624 $cached = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
625
626 if ( is_string( $cached ) && '' !== $cached ) {
627 return $cached;
628 }
629 }
630
631 add_filter( 'templately_request_source', [ $this, 'source_marker' ] );
632 $content = $this->fetch_content( $id );
633 remove_filter( 'templately_request_source', [ $this, 'source_marker' ] );
634
635 if ( ! is_string( $content ) || '' === $content ) {
636 return null;
637 }
638
639 $this->write_content_file( $id, $content );
640
641 return $content;
642 }
643
644 /**
645 * Write one inert content file. Rejects anything containing a PHP open-tag
646 * sequence: the file is later `include`d by WP's pattern registry, so a
647 * `<?` that slipped through would EXECUTE (research D2 — RCE guarantee).
648 */
649 public function write_content_file( int $id, string $content ): bool {
650 if ( false !== strpos( $content, '<?' ) ) {
651 Helper::log( "block-patterns: rejected content for item {$id} (contains PHP open tag)" );
652
653 return false;
654 }
655
656 $dir = $this->content_dir();
657 if ( ! wp_mkdir_p( $dir ) ) {
658 return false;
659 }
660 $this->harden_dir( $dir );
661
662 $tmp = $dir . "/{$id}.tmp";
663 if ( false === file_put_contents( $tmp, $content ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions
664 return false;
665 }
666
667 return rename( $tmp, $this->content_path( $id ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
668 }
669
670 public function content_path( int $id ): string {
671 return $this->content_dir() . "/{$id}.php";
672 }
673
674 /**
675 * Where cached pattern content lives.
676 *
677 * Filterable so tests can point it at a scratch directory. They MUST: the
678 * content top-up runner removes files whose id is absent from the current
679 * list, so a test that seeds a small fixture list and runs a batch will
680 * delete every real cached pattern on the site it runs against. That is
681 * exactly how a full test run once emptied a working sandbox.
682 */
683 public function content_dir(): string {
684 $uploads = wp_upload_dir( null, false );
685
686 return (string) apply_filters(
687 'templately_block_patterns_content_dir',
688 $uploads['basedir'] . '/templately/patterns'
689 );
690 }
691
692 /**
693 * Purge cached catalogs on a real DISCONNECT only.
694 *
695 * Deliberately narrow. This used to fire on any write to the stored account
696 * blob (`_templately_user`), but the plugin rewrites that blob on every
697 * routine profile sync — far more often than the plan ever changes — so the
698 * cache was emptied during ordinary admin page loads and patterns vanished
699 * from the inserter until the next cron run.
700 *
701 * Nothing else needs a purge: the cache is keyed BY plan, so a plan change
702 * simply reads a different key (a free account can never be served a pro
703 * catalog), and a disconnected site resolves `plan_key()` to null and serves
704 * nothing at all. Correctness comes from the keying, not from eviction —
705 * this hook only reclaims space once the site is genuinely disconnected.
706 */
707 public function maybe_invalidate_for_meta( string $meta_key ): void {
708 if ( false === strpos( $meta_key, '_templately_api_key' ) ) {
709 return;
710 }
711
712 // Only when the key is actually gone — `Options::set()` on connect writes
713 // this same meta, and purging there would throw away a catalog we just
714 // synced for the very account that is connecting.
715 if ( empty( Options::get_instance()->get( 'api_key' ) ) ) {
716 $this->invalidate_all();
717
718 return;
719 }
720
721 // Just connected — and this hook fires as the connecting user, which is
722 // the one context that reliably knows who owns the key.
723 $this->remember_owner();
724
725 // Sync NOW rather than waiting for the 12h cron: a fresh connection with
726 // an empty library is indistinguishable from a broken feature, and it read
727 // exactly that way twice during development.
728 if ( null === $this->get_list() ) {
729 $this->schedule_once( self::EVENT_SYNC_LIST, 0 );
730 }
731 }
732
733 public function invalidate_all(): void {
734 foreach ( self::PLAN_KEYS as $key ) {
735 Database::delete_transient( self::LIST_KEY_PREFIX . $key );
736 delete_option( self::SHADOW_KEY_PREFIX . $key );
737
738 // A parked cursor left behind would make the next composition resume
739 // into a catalog that no longer exists, producing a list missing every
740 // type before the cursor.
741 delete_option( self::PROGRESS_KEY_PREFIX . $key );
742 }
743
744 // The type axis too — it is what the next sync composes against, so a
745 // "clear the pattern library" that left it behind would rebuild the same
746 // catalog shape from a cache the user just asked to be rid of.
747 Database::delete_transient( self::TYPES_KEY );
748
749 delete_option( self::OWNER_OPTION );
750 }
751
752 /**
753 * Schedule the first sync when nothing has ever synced (admin_init).
754 */
755 public function ensure_scheduled(): void {
756 if ( null === $this->plan_key() ) {
757 return;
758 }
759
760 // admin_init has a real user, so this is where the cron run gets told
761 // whose connection to use.
762 $this->remember_owner();
763
764 if ( null === $this->get_list() && ! wp_next_scheduled( self::EVENT_SYNC_LIST ) ) {
765 $this->schedule_once( self::EVENT_SYNC_LIST );
766 }
767 }
768
769 /**
770 * @param int $delay Seconds from now; 0 makes the event due immediately so
771 * the very next request runs it.
772 */
773 private function schedule_once( string $event, int $delay = MINUTE_IN_SECONDS ): void {
774 if ( ! wp_next_scheduled( $event ) ) {
775 wp_schedule_single_event( time() + $delay, $event );
776 }
777 }
778
779 /**
780 * Fetch one catalog endpoint and normalise it into Catalog Items.
781 *
782 * `kind` comes from WHICH endpoint answered, not from a field on the item:
783 * the blocks endpoint returns sections and the pages endpoint returns page
784 * templates, and the payload itself does not distinguish them reliably.
785 *
786 * @param string $endpoint Cloud query name (`items` | `pages`).
787 * @param string $kind Kind to stamp on everything it returns.
788 * @return array<int, array>
789 */
790 /**
791 * The two full pages the chooser gets: newest, and most downloaded.
792 *
793 * @return array<int, array>
794 */
795 private function fetch_featured_pages( string $plan_key ): array {
796 $pages = [];
797
798 foreach ( self::FEATURED_PAGE_SORTS as $sort ) {
799 $total_pages = 1;
800
801 $batch = $this->fetch_page( 'pages', 'page', 1, $plan_key, 1, $total_pages, '', [ 'sort_by' => $sort ] );
802
803 if ( ! empty( $batch[0] ) ) {
804 $pages[] = $batch[0];
805 }
806 }
807
808 return $pages;
809 }
810
811 /**
812 * Where the last run stopped, and what it had collected by then.
813 *
814 * A cursor of 0 means "start a fresh composition" — which is also what a
815 * completed run leaves behind, so the next scheduled sync recomposes from
816 * the top rather than resuming a finished one.
817 *
818 * @return array{cursor:int, items:array<int, array>}
819 */
820 private function composition_progress( string $plan_key ): array {
821 $saved = get_option( self::PROGRESS_KEY_PREFIX . $plan_key );
822
823 if ( ! is_array( $saved ) || empty( $saved['cursor'] ) ) {
824 return [ 'cursor' => 0, 'items' => [] ];
825 }
826
827 return [
828 'cursor' => (int) $saved['cursor'],
829 'items' => isset( $saved['items'] ) && is_array( $saved['items'] ) ? $saved['items'] : [],
830 ];
831 }
832
833 /**
834 * Park the cursor and chain the next batch, or clear it when complete.
835 *
836 * @param int $cursor Next type index, or 0 when the composition finished.
837 * @param array $items Everything collected so far.
838 * @return void
839 */
840 private function save_composition_progress( string $plan_key, int $cursor, array $items ): void {
841 if ( $cursor < 1 ) {
842 delete_option( self::PROGRESS_KEY_PREFIX . $plan_key );
843
844 return;
845 }
846
847 update_option( self::PROGRESS_KEY_PREFIX . $plan_key, [
848 'cursor' => $cursor,
849 'items' => $items,
850 ], false );
851
852 // Chain immediately rather than waiting for the 12h cycle. If cron never
853 // fires — the failure mode this module already plans around — the editor's
854 // own sync request picks the composition up instead, because a parked
855 // cursor makes the cache "not fresh enough" (see sync_if_stale()).
856 $this->schedule_once( self::EVENT_SYNC_LIST, 0 );
857 }
858
859 /**
860 * The section template types, cached.
861 *
862 * `groupedCategories` is the cloud's own axis and takes no platform argument,
863 * so it can name a type that has no Gutenberg designs at all. Those cost one
864 * request that returns nothing and are then absent from the catalog, which is
865 * the correct outcome — filtering them out up front would mean trusting
866 * `platforms`, a free-form string, to be parseable.
867 *
868 * @return array<int, array{id:int, slug:string}>
869 */
870 private function section_template_types(): array {
871 $cached = Database::get_transient( self::TYPES_KEY );
872
873 if ( is_array( $cached ) ) {
874 return $cached;
875 }
876
877 $response = Http::get_instance()
878 ->query( 'groupedCategories', 'item_categories { id, name, slug, type }', [] )
879 ->post();
880
881 if ( is_wp_error( $response ) || empty( $response['item_categories'] ) ) {
882 return [];
883 }
884
885 $types = [];
886
887 foreach ( (array) $response['item_categories'] as $type ) {
888 $id = absint( $type['id'] ?? 0 );
889
890 // `type` is the cloud's page-vs-block marker. Pages are composed from
891 // FEATURED_PAGE_SORTS, not fanned out per type, so only blocks here.
892 if ( ! $id || ( ! empty( $type['type'] ) && 'block' !== $type['type'] ) ) {
893 continue;
894 }
895
896 $types[] = [
897 'id' => $id,
898 'slug' => sanitize_key( $type['slug'] ?? '' ),
899 ];
900 }
901
902 Database::set_transient( self::TYPES_KEY, $types, self::TYPES_TTL );
903
904 return $types;
905 }
906
907 /**
908 * Cut the catalog to the ceiling WITHOUT emptying whole categories.
909 *
910 * The composition appends one category at a time, so a plain
911 * `array_slice( $items, 0, $ceiling )` removes the LAST categories entirely
912 * — reinstating exactly the empty-sidebar problem composing by category
913 * exists to fix, just at the tail instead of the middle. It also drops the
914 * two featured pages last, which is the wrong order: they are 2 rows and the
915 * only thing feeding the new-page chooser.
916 *
917 * So: keep the pages, then take from each category in turn until the budget
918 * is spent. Every category keeps its most-downloaded designs (the cloud's own
919 * order within a category is preserved) and every category keeps something.
920 *
921 * @param array<int, array> $items
922 * @return array<int, array>
923 */
924 private function trim_evenly( array $items, int $ceiling ): array {
925 $pages = [];
926 $by_group = [];
927
928 foreach ( $items as $item ) {
929 if ( isset( $item['kind'] ) && 'page' === $item['kind'] ) {
930 $pages[] = $item;
931 continue;
932 }
933
934 $group = isset( $item['category'] ) ? (string) $item['category'] : 'general';
935 $by_group[ $group ][] = $item;
936 }
937
938 $kept = array_slice( $pages, 0, $ceiling );
939
940 // Round-robin, so the cut falls on the deepest categories rather than on
941 // whichever ones happen to sort last.
942 $round = 0;
943 while ( count( $kept ) < $ceiling ) {
944 $took = false;
945
946 foreach ( $by_group as $group => $group_items ) {
947 if ( ! isset( $group_items[ $round ] ) ) {
948 continue;
949 }
950
951 $kept[] = $group_items[ $round ];
952 $took = true;
953
954 if ( count( $kept ) >= $ceiling ) {
955 break;
956 }
957 }
958
959 if ( ! $took ) {
960 break; // every category exhausted
961 }
962
963 $round++;
964 }
965
966 return $kept;
967 }
968
969 /**
970 * Collapse designs returned by more than one query, keeping the first seen.
971 *
972 * @param array<int, array> $items
973 * @return array<int, array>
974 */
975 private function unique_by_id( array $items ): array {
976 $seen = [];
977 $unique = [];
978
979 foreach ( $items as $item ) {
980 $id = (int) ( $item['id'] ?? 0 );
981
982 if ( ! $id || isset( $seen[ $id ] ) ) {
983 continue;
984 }
985
986 $seen[ $id ] = true;
987 $unique[] = $item;
988 }
989
990 return $unique;
991 }
992
993 private function fetch_type( string $endpoint, string $kind, int $limit, string $plan_key, array $extra = [] ): array {
994 if ( $limit < 1 ) {
995 return [];
996 }
997
998 // The cloud caps its own page size, so one request returns fewer rows than
999 // `per_page` asks for and reports the real total in `total_page`. Ignoring
1000 // that capped the catalog at whatever page 1 happened to hold.
1001 $items = [];
1002 $page = 1;
1003 $total_pages = 1; // replaced by the first response; must be set before the guard reads it
1004
1005 do {
1006 $batch = $this->fetch_page( $endpoint, $kind, $limit, $plan_key, $page, $total_pages, '', $extra );
1007 $items = array_merge( $items, $batch );
1008 $page++;
1009 } while ( count( $items ) < $limit && $page <= (int) $total_pages && $page <= self::MAX_PAGES );
1010
1011 return array_slice( $items, 0, $limit );
1012 }
1013
1014 /**
1015 * One page of one endpoint.
1016 *
1017 * @param int|null $total_pages Set to the endpoint's reported page count.
1018 * @return array<int, array>
1019 */
1020 /**
1021 * The catalog's dependency rows, sanitized but NOT renamed.
1022 *
1023 * The field names stay exactly as the cloud sends them — `plugin_file`,
1024 * `plugin_original_slug`, `is_pro`, `link` — because these rows are handed
1025 * straight to `templately/v1/dependencies/check` and `.../install`, the same
1026 * endpoints the full-site and single-import dependency steps post to. Renaming
1027 * them into a shape of our own would mean writing a second installer for a
1028 * problem that already has one.
1029 *
1030 * Anything without a `plugin_file` is dropped: `check_dependencies()` skips
1031 * such a row anyway, and there would be nothing to install.
1032 *
1033 * @param mixed $dependencies Raw `dependencies` array from the items query.
1034 * @return array<int,array<string,mixed>>
1035 */
1036 private static function normalize_dependencies( $dependencies ): array {
1037 if ( ! is_array( $dependencies ) ) {
1038 return [];
1039 }
1040
1041 $rows = [];
1042 foreach ( $dependencies as $dependency ) {
1043 $dependency = (array) $dependency;
1044 $file = isset( $dependency['plugin_file'] ) ? (string) $dependency['plugin_file'] : '';
1045
1046 // `plugin_file` is a path (`folder/file.php`), so sanitize_text_field is
1047 // the wrong tool — strip anything that could escape the plugins dir.
1048 $file = ltrim( str_replace( [ '..', '\\' ], '', $file ), '/' );
1049 if ( '' === $file || false === strpos( $file, '/' ) ) {
1050 continue;
1051 }
1052
1053 $rows[] = [
1054 'plugin_file' => $file,
1055 'name' => sanitize_text_field( (string) ( $dependency['name'] ?? '' ) ),
1056 'plugin_original_slug' => sanitize_key( (string) ( $dependency['plugin_original_slug'] ?? '' ) ),
1057 'is_pro' => ! empty( $dependency['is_pro'] ),
1058 'link' => esc_url_raw( (string) ( $dependency['link'] ?? '' ) ),
1059 ];
1060 }
1061
1062 return $rows;
1063 }
1064
1065 private function fetch_page( string $endpoint, string $kind, int $limit, string $plan_key, int $page, &$total_pages, string $search = '', array $extra = [] ): array {
1066 $total_pages = 1;
1067
1068 $args = [
1069 'platform' => 'gutenberg',
1070 'page' => $page,
1071 'per_page' => $limit,
1072 'sort_by' => 'download',
1073 ];
1074
1075 // Composition arguments — `template_type_id` for the per-type fan-out, and
1076 // `sort_by` for the two featured pages. Merged BEFORE the search branch so
1077 // a live search still wins on ordering, which is its whole point.
1078 if ( $extra ) {
1079 $args = array_merge( $args, $extra );
1080 }
1081
1082 // The cloud's own relevance ordering, which only applies when searching.
1083 if ( '' !== $search ) {
1084 $args['search'] = $search;
1085 $args['sort_by'] = 'latest';
1086 }
1087
1088 // `dependencies` is the CLOUD's own answer to "what does this design need",
1089 // the same field the full-site and single-import dependency steps read. It
1090 // carries the plugin file, the wordpress.org slug and the purchase link, so
1091 // nothing on our side has to infer a plugin from a block namespace — which
1092 // cannot be done reliably anyway: a free plugin may register its paid tier's
1093 // block names as upsell stubs, and core's own block-directory search resolves
1094 // `woocommerce/mini-cart` to an unrelated plugin called OffCanvas.
1095 //
1096 // `pack` is what lets the insert apply the pack's global colours and
1097 // typography, the way single-import already does. Both fields were already
1098 // available on this query; asking for them costs nothing extra.
1099 $query = 'total_page, current_page, data { id, name, price, type, template_type{ slug }, slug, thumbnail, tags{ name }, pack { id, has_settings }, dependencies{ name, plugin_file, plugin_original_slug, is_pro, link } }';
1100 $response = Http::get_instance()->query( $endpoint, $query, $args )->post();
1101
1102 if ( is_wp_error( $response ) || empty( $response['data'] ) || ! is_array( $response['data'] ) ) {
1103 return [];
1104 }
1105
1106 $total_pages = isset( $response['total_page'] ) ? (int) $response['total_page'] : 1;
1107
1108 $items = [];
1109 foreach ( $response['data'] as $item ) {
1110 if ( empty( $item['id'] ) ) {
1111 continue;
1112 }
1113
1114 $is_pro = ! empty( $item['price'] ) && (float) $item['price'] > 0;
1115 // Free accounts must never even cache a pro item under their key —
1116 // registration-side gating starts at the cache boundary (FR-003).
1117 if ( 'free' === $plan_key && $is_pro ) {
1118 continue;
1119 }
1120
1121 $items[] = [
1122 'id' => absint( $item['id'] ),
1123 'title' => sanitize_text_field( $item['name'] ?? '' ),
1124 'slug' => sanitize_key( $item['slug'] ?? '' ),
1125 'kind' => $kind,
1126 'plan' => $is_pro ? 'pro' : 'free',
1127 'category' => sanitize_key( $item['template_type']['slug'] ?? 'general' ),
1128 'preview_url' => esc_url_raw( $item['thumbnail'] ?? '' ),
1129 // Feeds the inserter's `keywords` scoring field.
1130 'tags' => array_values( array_filter( array_map(
1131 'sanitize_text_field',
1132 wp_list_pluck( (array) ( $item['tags'] ?? [] ), 'name' )
1133 ) ) ),
1134 // What this design needs installed, straight from the catalog.
1135 'requires' => self::normalize_dependencies( $item['dependencies'] ?? [] ),
1136 // Pack provenance, read at insert time to merge the pack's global
1137 // settings into the markup. Both absent for a standalone item, and
1138 // absent from any cache written before this field existed — the
1139 // insert then behaves exactly as it did before, unstyled but working,
1140 // until the next resync fills them in.
1141 'pack_id' => absint( $item['pack']['id'] ?? 0 ),
1142 'has_settings' => ! empty( $item['pack']['has_settings'] ),
1143 ];
1144 }
1145
1146 return $items;
1147 }
1148
1149 /**
1150 * @return int[] Item ids in the current list with no content file yet.
1151 */
1152 private function missing_content_ids(): array {
1153 $list = $this->get_list();
1154 if ( null === $list ) {
1155 return [];
1156 }
1157
1158 $missing = [];
1159 foreach ( $list['items'] as $item ) {
1160 if ( ! file_exists( $this->content_path( (int) $item['id'] ) ) ) {
1161 $missing[] = (int) $item['id'];
1162 }
1163 }
1164
1165 return $missing;
1166 }
1167
1168 private function remove_orphan_files( array $live_ids ): void {
1169 $live = array_map( 'intval', $live_ids );
1170 $files = glob( $this->content_dir() . '/*.php' );
1171 foreach ( (array) $files as $file ) {
1172 $basename = basename( $file, '.php' );
1173 if ( 'index' === $basename ) {
1174 continue;
1175 }
1176 if ( ! in_array( (int) $basename, $live, true ) ) {
1177 wp_delete_file( $file );
1178 }
1179 }
1180 }
1181
1182 private function fetch_content( int $id ): ?string {
1183 $api_key = Options::get_instance()->get( 'api_key' );
1184 $response = Http::get_instance()->query( 'itemContent', 'status, message, data', [
1185 'api_key' => $api_key,
1186 'id' => $id,
1187 ] )->post();
1188
1189 if ( is_wp_error( $response ) || empty( $response['data'] ) ) {
1190 return null;
1191 }
1192
1193 $data = is_string( $response['data'] ) ? json_decode( $response['data'], true ) : (array) $response['data'];
1194
1195 return isset( $data['content'] ) && is_string( $data['content'] ) ? $data['content'] : null;
1196 }
1197
1198 private function harden_dir( string $dir ): void {
1199 if ( ! file_exists( $dir . '/.htaccess' ) ) {
1200 file_put_contents( $dir . '/.htaccess', "Deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1201 }
1202 if ( ! file_exists( $dir . '/index.php' ) ) {
1203 file_put_contents( $dir . '/index.php', "<?php // Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1204 }
1205 }
1206 }
1207