PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / api / snippet-rest-api.php

snippet-rest-api.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/api/snippet-rest-api.php

1,761 lines 57.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet REST API - Manage ZIP AI code snippets via REST endpoints.
4 *
5 * Endpoints:
6 * GET /snippets — List all snippets
7 * POST /snippets — Create new snippet
8 * GET /snippets/{slug} — Get single snippet with file contents
9 * POST /snippets/{slug} — Update snippet
10 * DELETE /snippets/{slug} — Delete snippet
11 * POST /snippets/{slug}/toggle — Toggle enable/disable
12 * GET /snippets/export — Export selected snippets as JSON
13 * POST /snippets/import — Import snippets from JSON
14 *
15 * @since 0.1.0
16 * @package zip-ai
17 */
18
19 namespace ZipAI\MCP\Classes\Api;
20
21 use ZipAI\MCP\Classes\Core\Snippet_Store;
22 use ZipAI\MCP\Classes\Core\Snippet_Versions;
23 use ZipAI\MCP\Classes\Core\Snippet_Lint;
24 use ZipAI\MCP\Classes\Core\Utils;
25
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 class Snippet_REST_API {
31
32 /**
33 * REST API route namespace.
34 *
35 * @var non-falsy-string
36 */
37 private $namespace = 'zip-ai/v1';
38
39 private const MAX_CODE_SIZE = 102400; // 100KB
40
41 /**
42 * Wire up the REST route registration hook.
43 *
44 * @return void
45 */
46 public function __construct() {
47 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
48 }
49
50 // ── Routes ─────────────────────────────────────────────────────────
51
52 /**
53 * Register all snippet REST routes.
54 *
55 * @return void
56 */
57 public function register_routes() {
58 $slug_args = array(
59 'slug' => array(
60 'required' => true,
61 'type' => 'string',
62 'sanitize_callback' => 'sanitize_file_name',
63 'validate_callback' => function ( $value ) {
64 return (bool) preg_match( '/^[a-zA-Z0-9_-]+$/', Utils::to_str( $value ) );
65 },
66 ),
67 );
68
69 // List all.
70 register_rest_route(
71 $this->namespace,
72 '/snippets',
73 array(
74 'methods' => 'GET',
75 'callback' => array( $this, 'get_snippets' ),
76 'permission_callback' => array( $this, 'check_permission' ),
77 )
78 );
79
80 // Create new.
81 register_rest_route(
82 $this->namespace,
83 '/snippets',
84 array(
85 'methods' => 'POST',
86 'callback' => array( $this, 'create_snippet' ),
87 'permission_callback' => array( $this, 'check_write_permission' ),
88 )
89 );
90
91 // Export.
92 register_rest_route(
93 $this->namespace,
94 '/snippets/export',
95 array(
96 'methods' => 'GET',
97 'callback' => array( $this, 'export_snippets' ),
98 'permission_callback' => array( $this, 'check_permission' ),
99 )
100 );
101
102 // Import.
103 register_rest_route(
104 $this->namespace,
105 '/snippets/import',
106 array(
107 'methods' => 'POST',
108 'callback' => array( $this, 'import_snippets' ),
109 'permission_callback' => array( $this, 'check_write_permission' ),
110 )
111 );
112
113 // Safe mode toggle.
114 register_rest_route(
115 $this->namespace,
116 '/snippets/safe-mode',
117 array(
118 'methods' => 'POST',
119 'callback' => array( $this, 'toggle_safe_mode' ),
120 'permission_callback' => array( $this, 'check_permission' ),
121 )
122 );
123
124 // NOTE: the `/snippets/{slug}/test` route was removed — it `exec()`d
125 // request-body PHP with no lint and no create/enable step, i.e. a
126 // direct arbitrary-code-execution surface, and nothing in the UI called
127 // it. Sandbox mode (below) is how a snippet is trialled.
128
129 // Sandbox mode toggle.
130 register_rest_route(
131 $this->namespace,
132 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)/sandbox',
133 array(
134 'methods' => 'POST',
135 'callback' => array( $this, 'toggle_sandbox' ),
136 'permission_callback' => array( $this, 'check_write_permission' ),
137 'args' => $slug_args,
138 )
139 );
140
141 // Toggle.
142 register_rest_route(
143 $this->namespace,
144 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)/toggle',
145 array(
146 'methods' => 'POST',
147 'callback' => array( $this, 'toggle_snippet' ),
148 'permission_callback' => array( $this, 'check_permission' ),
149 'args' => $slug_args,
150 )
151 );
152
153 // Targeting lookups — autocomplete posts/pages for the post-picker condition.
154 register_rest_route(
155 $this->namespace,
156 '/snippets/lookup/posts',
157 array(
158 'methods' => 'GET',
159 'callback' => array( $this, 'lookup_posts' ),
160 'permission_callback' => array( $this, 'check_permission' ),
161 )
162 );
163
164 // Versions — list / create manual snapshot.
165 register_rest_route(
166 $this->namespace,
167 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions',
168 array(
169 array(
170 'methods' => 'GET',
171 'callback' => array( $this, 'list_versions' ),
172 'permission_callback' => array( $this, 'check_permission' ),
173 'args' => $slug_args,
174 ),
175 array(
176 'methods' => 'POST',
177 'callback' => array( $this, 'create_manual_version' ),
178 'permission_callback' => array( $this, 'check_write_permission' ),
179 'args' => $slug_args,
180 ),
181 )
182 );
183
184 // Versions — diff against another version (or current HEAD by default).
185 register_rest_route(
186 $this->namespace,
187 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions/(?P<id>[a-zA-Z0-9_-]+)/diff',
188 array(
189 'methods' => 'GET',
190 'callback' => array( $this, 'diff_version' ),
191 'permission_callback' => array( $this, 'check_permission' ),
192 'args' => $slug_args,
193 )
194 );
195
196 // Versions — restore.
197 register_rest_route(
198 $this->namespace,
199 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions/(?P<id>[a-zA-Z0-9_-]+)/restore',
200 array(
201 'methods' => 'POST',
202 'callback' => array( $this, 'restore_version' ),
203 'permission_callback' => array( $this, 'check_write_permission' ),
204 'args' => $slug_args,
205 )
206 );
207
208 // Versions — get / delete one.
209 register_rest_route(
210 $this->namespace,
211 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions/(?P<id>[a-zA-Z0-9_-]+)',
212 array(
213 array(
214 'methods' => 'GET',
215 'callback' => array( $this, 'get_version' ),
216 'permission_callback' => array( $this, 'check_permission' ),
217 'args' => $slug_args,
218 ),
219 array(
220 'methods' => 'DELETE',
221 'callback' => array( $this, 'delete_version' ),
222 'permission_callback' => array( $this, 'check_permission' ),
223 'args' => $slug_args,
224 ),
225 )
226 );
227
228 // Get / Update / Delete single.
229 register_rest_route(
230 $this->namespace,
231 '/snippets/(?P<slug>[a-zA-Z0-9_-]+)',
232 array(
233 array(
234 'methods' => 'GET',
235 'callback' => array( $this, 'get_snippet' ),
236 'permission_callback' => array( $this, 'check_permission' ),
237 'args' => $slug_args,
238 ),
239 array(
240 'methods' => 'POST',
241 'callback' => array( $this, 'update_snippet' ),
242 'permission_callback' => array( $this, 'check_write_permission' ),
243 'args' => $slug_args,
244 ),
245 array(
246 'methods' => 'DELETE',
247 'callback' => array( $this, 'delete_snippet' ),
248 'permission_callback' => array( $this, 'check_permission' ),
249 'args' => $slug_args,
250 ),
251 )
252 );
253 }
254
255 /**
256 * Permission callback for inspect-and-switch-off routes — requires
257 * `Snippet_Store::read_capability()` (`manage_options` by default).
258 *
259 * Kept below the write cap on purpose: the executor has no cap check, so an
260 * enabled snippet keeps running regardless. Locking these routes behind the
261 * file-editor cap would leave a DISALLOW_FILE_EDIT host or a multisite
262 * sub-site admin with live snippets they cannot inspect, disable or delete.
263 *
264 * @return true|\WP_Error True when allowed, error response otherwise.
265 */
266 public function check_permission() {
267 if ( Snippet_Store::current_user_can_read() ) {
268 return true;
269 }
270 return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to manage snippets.', 'zip-ai' ), array( 'status' => 403 ) );
271 }
272
273 /**
274 * Permission callback for routes that put PHP on disk or turn it on —
275 * requires `Snippet_Store::manage_capability()` (`edit_plugins` by default).
276 *
277 * `manage_options` is NOT sufficient: a multisite sub-site admin holds it
278 * while being denied the file editor, and a site with DISALLOW_FILE_EDIT has
279 * asked not to have code written at all.
280 *
281 * @return true|\WP_Error True when allowed, error response otherwise.
282 */
283 public function check_write_permission() {
284 if ( Snippet_Store::current_user_can_manage() ) {
285 return true;
286 }
287 return new \WP_Error(
288 'rest_forbidden',
289 __( 'You do not have permission to create, change or enable snippets. Snippet code runs on your site, so this requires the same capability as the plugin file editor.', 'zip-ai' ),
290 array( 'status' => 403 )
291 );
292 }
293
294 // ── List ───────────────────────────────────────────────────────────
295
296 /**
297 * List all snippets.
298 *
299 * @return \WP_REST_Response Response carrying the snippets list.
300 */
301 public function get_snippets() {
302 /**
303 * Narrowed type for `$manifest`.
304 *
305 * @var array<string, array<string, mixed>> $manifest
306 */
307 $manifest = Snippet_Store::load();
308 $snippets = array();
309
310 foreach ( $manifest as $slug => $meta ) {
311 if ( '_version' === $slug ) {
312 continue;
313 }
314 $snippets[] = $this->format_snippet( $slug, $meta );
315 }
316
317 return new \WP_REST_Response( array( 'snippets' => $snippets ), 200 );
318 }
319
320 // ── Get Single ─────────────────────────────────────────────────────
321
322 /**
323 * Get a single snippet including its file contents.
324 *
325 * @param \WP_REST_Request $request REST request.
326 * @return \WP_REST_Response Response with the snippet or an error.
327 */
328 public function get_snippet( $request ) {
329 $slug = Snippet_Store::validate_slug( Utils::to_str( $request->get_param( 'slug' ) ) );
330 if ( ! $slug ) {
331 return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
332 }
333
334 /**
335 * Narrowed type for `$manifest`.
336 *
337 * @var array<string, array<string, mixed>> $manifest
338 */
339 $manifest = Snippet_Store::load();
340 if ( ! isset( $manifest[ $slug ] ) ) {
341 return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
342 }
343
344 $snippet = $this->format_snippet( $slug, $manifest[ $slug ] );
345 $snippet['file_contents'] = $this->read_snippet_files( $slug );
346
347 return new \WP_REST_Response( $snippet, 200 );
348 }
349
350 // ── Create ─────────────────────────────────────────────────────────
351
352 /**
353 * Create a new snippet.
354 *
355 * @since 0.0.5
356 * @param \WP_REST_Request $request REST request.
357 * @return \WP_REST_Response Response with the created snippet or an error.
358 */
359 public function create_snippet( $request ) {
360 $body = $request->get_json_params();
361 $title = sanitize_text_field( Utils::to_str( $body['title'] ?? '' ) );
362
363 if ( empty( $title ) ) {
364 return new \WP_REST_Response( array( 'error' => 'Title is required.' ), 400 );
365 }
366
367 // Generate slug from title.
368 $slug = sanitize_title( $title );
369 $slug = Snippet_Store::validate_slug( $slug );
370 if ( ! $slug ) {
371 return new \WP_REST_Response( array( 'error' => 'Could not generate valid slug from title.' ), 400 );
372 }
373
374 /**
375 * Loaded snippets manifest keyed by slug.
376 *
377 * @var array<string, array<string, mixed>> $manifest
378 */
379 $manifest = Snippet_Store::load();
380
381 // Handle slug conflicts.
382 $original_slug = $slug;
383 $counter = 1;
384 while ( isset( $manifest[ $slug ] ) ) {
385 $slug = $original_slug . '-' . $counter;
386 ++$counter;
387 }
388
389 /**
390 * Narrowed type for `$file_contents`.
391 *
392 * @var array<string, mixed> $file_contents
393 */
394 $file_contents = $body['file_contents'] ?? array();
395 $files = array();
396 $file_hashes = array();
397 $execution = $this->sanitize_execution( $body['execution'] ?? array() );
398
399 // Refuse a create whose targeting could not be represented faithfully
400 // — persisting looser constraints than asked widens where the code
401 // runs once enabled.
402 $conditions_result = $this->sanitize_conditions( $body['conditions'] ?? array() );
403 if ( ! empty( $conditions_result['rejected'] ) ) {
404 return new \WP_REST_Response(
405 array( 'error' => 'Targeting conditions rejected — nothing was saved: ' . implode( '; ', $conditions_result['rejected'] ) ),
406 400
407 );
408 }
409
410 // Validate + wrap EVERY file before touching the filesystem. Minting the
411 // dir first would leave `base/<slug>-xxxxxxxx/` behind on a rejected
412 // create, with a fresh random suffix on every retry and no manifest
413 // entry — invisible to the UI, undeletable through it.
414 $prepared = array();
415 foreach ( $file_contents as $type => $content ) {
416 $type = sanitize_key( $type );
417 if ( ! in_array( $type, Snippet_Store::ALLOWED_TYPES, true ) ) {
418 continue;
419 }
420
421 $validation = $this->validate_code( $type, Utils::to_str( $content ), Utils::to_str( $execution[ $type ]['hook'] ?? '' ) );
422 if ( is_wp_error( $validation ) ) {
423 return new \WP_REST_Response( array( 'error' => $validation->get_error_message() ), 400 );
424 }
425
426 $stored = 'php' === $type
427 ? Snippet_Store::prepare_php_file( Utils::to_str( $content ), $title )
428 : Utils::to_str( $content );
429 if ( is_wp_error( $stored ) ) {
430 return new \WP_REST_Response( array( 'error' => $stored->get_error_message() ), 400 );
431 }
432
433 $prepared[ $type ] = $stored;
434 }
435
436 // Randomized on-disk dir + HTTP-deny protection files.
437 $dir_name = Snippet_Store::prepare_snippet_dir( $slug, true );
438 $snippet_dir = Snippet_Store::snippet_dir( $slug );
439
440 foreach ( $prepared as $type => $stored ) {
441 $filepath = $snippet_dir . '/snippet.' . $type;
442 if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
443 $this->remove_directory( $snippet_dir );
444 return new \WP_REST_Response( array( 'error' => 'Refused unsafe write path.' ), 400 );
445 }
446 Snippet_Store::put_file( $filepath, $stored );
447 Snippet_Store::invalidate_opcache( $filepath );
448 $files[] = $type;
449 $file_hashes[ $type ] = hash_file( 'sha256', $filepath );
450 }
451
452 // Build manifest entry.
453 $manifest[ $slug ] = array(
454 'title' => $title,
455 'dir' => $dir_name, // randomized on-disk basename
456 'description' => sanitize_text_field( Utils::to_str( $body['description'] ?? '' ) ),
457 'files' => $files,
458 'file_hashes' => $file_hashes,
459 'enabled' => false,
460 'execution' => $execution,
461 'conditions' => $conditions_result['rows'],
462 'created' => gmdate( 'Y-m-d H:i:s' ),
463 'created_by' => get_current_user_id(),
464 'updated' => gmdate( 'Y-m-d H:i:s' ),
465 'updated_by' => get_current_user_id(),
466 'updated_by_type' => 'user',
467 );
468
469 Snippet_Store::save( $manifest );
470
471 // Seed v1 from the just-written HEAD files.
472 Snippet_Versions::create(
473 $slug,
474 array(
475 'author_type' => 'user',
476 'author_id' => get_current_user_id(),
477 'author_name' => $this->current_user_display_name(),
478 'reason' => 'Initial version',
479 'manual' => false,
480 )
481 );
482
483 // Re-load manifest because Snippet_Versions::create() updated the version pointer.
484 /**
485 * Narrowed type for `$manifest`.
486 *
487 * @var array<string, array<string, mixed>> $manifest
488 */
489 $manifest = Snippet_Store::load();
490
491 return new \WP_REST_Response(
492 array(
493 'success' => true,
494 'slug' => $slug,
495 'snippet' => $this->format_snippet( $slug, $manifest[ $slug ] ),
496 ),
497 201
498 );
499 }
500
501 // ── Update ─────────────────────────────────────────────────────────
502
503 /**
504 * Update an existing snippet.
505 *
506 * @param \WP_REST_Request $request REST request.
507 * @return \WP_REST_Response Response with the updated snippet or an error.
508 */
509 public function update_snippet( $request ) {
510 $slug = Snippet_Store::validate_slug( Utils::to_str( $request->get_param( 'slug' ) ) );
511 if ( ! $slug ) {
512 return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
513 }
514
515 /**
516 * Narrowed type for `$manifest`.
517 *
518 * @var array<string, array<string, mixed>> $manifest
519 */
520 $manifest = Snippet_Store::load();
521 if ( ! isset( $manifest[ $slug ] ) ) {
522 return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
523 }
524
525 $body = $request->get_json_params();
526
527 // Keeps the existing dir, but back-fills anything a snippet written by
528 // an older build is missing: the base-dir deny files, and this dir's
529 // `.htaccess` / `web.config` / `index.php`. Saving is what migrates an
530 // existing snippet — its body also picks up the ABSPATH wrapper below.
531 Snippet_Store::prepare_snippet_dir( $slug, false );
532
533 $snippet_dir = Snippet_Store::snippet_dir( $slug );
534 $applied = array();
535 $snapshot_needed = false;
536
537 // Update description if provided.
538 if ( isset( $body['description'] ) ) {
539 $description = sanitize_text_field( Utils::to_str( $body['description'] ) );
540 if ( ( $manifest[ $slug ]['description'] ?? '' ) !== $description ) {
541 $snapshot_needed = true;
542 }
543 $manifest[ $slug ]['description'] = $description;
544 $applied[] = 'description';
545 }
546
547 // Update execution if provided — merged over the PERSISTED execution:
548 // a partial payload must not reset the other file types' bindings
549 // back to defaults.
550 if ( isset( $body['execution'] ) && is_array( $body['execution'] ) ) {
551 $persisted_execution = is_array( $manifest[ $slug ]['execution'] ?? null ) ? $manifest[ $slug ]['execution'] : null;
552 $execution = $this->sanitize_execution( $body['execution'], $persisted_execution );
553 if ( ( $manifest[ $slug ]['execution'] ?? array() ) !== $execution ) {
554 $snapshot_needed = true;
555 }
556 $manifest[ $slug ]['execution'] = $execution;
557 $applied[] = 'execution';
558 }
559
560 // Update conditions if provided. A row that could not be represented
561 // faithfully rejects the whole write — persisting looser targeting
562 // than asked widens where live code runs.
563 if ( isset( $body['conditions'] ) && is_array( $body['conditions'] ) ) {
564 $conditions = $this->sanitize_conditions( $body['conditions'] );
565 if ( ! empty( $conditions['rejected'] ) ) {
566 return new \WP_REST_Response(
567 array( 'error' => 'Targeting conditions rejected — nothing was saved: ' . implode( '; ', $conditions['rejected'] ) ),
568 400
569 );
570 }
571 if ( ( $manifest[ $slug ]['conditions'] ?? array() ) !== $conditions['rows'] ) {
572 $snapshot_needed = true;
573 }
574 $manifest[ $slug ]['conditions'] = $conditions['rows'];
575 $applied[] = 'conditions';
576 }
577
578 // Delete files for types removed from the intended set. The React
579 // editor sends `files` as the post-save desired list — anything in
580 // the manifest's existing `files` but missing from this payload gets
581 // dropped from disk + manifest. This lets the UI uncheck a file type
582 // and have the actual file removed on save.
583 if ( isset( $body['files'] ) && is_array( $body['files'] ) ) {
584 $desired = array_values(
585 array_intersect(
586 array_map( fn( $file ) => sanitize_key( Utils::to_str( $file ) ), $body['files'] ),
587 Snippet_Store::ALLOWED_TYPES
588 )
589 );
590 /**
591 * Narrowed type for `$current`.
592 *
593 * @var list<string> $current
594 */
595 $current = $manifest[ $slug ]['files'] ?? array();
596 $removed = array_diff( $current, $desired );
597 foreach ( $removed as $type ) {
598 $filepath = $snippet_dir . '/snippet.' . $type;
599 if ( Snippet_Store::is_safe_path( $filepath ) && file_exists( $filepath ) ) {
600 wp_delete_file( $filepath );
601 }
602 $hashes = $manifest[ $slug ]['file_hashes'] ?? array();
603 if ( is_array( $hashes ) && isset( $hashes[ $type ] ) ) {
604 unset( $hashes[ $type ] );
605 $manifest[ $slug ]['file_hashes'] = $hashes;
606 }
607 }
608 if ( ! empty( $removed ) ) {
609 $manifest[ $slug ]['files'] = array_values( array_diff( $current, $removed ) );
610 $applied[] = 'files_removed';
611 $snapshot_needed = true;
612 }
613 }
614
615 // Update file contents if provided. Track whether any file content
616 // actually changed so we can record a new version snapshot.
617 $content_changed = false;
618 if ( ! empty( $body['file_contents'] ) && is_array( $body['file_contents'] ) ) {
619 foreach ( $body['file_contents'] as $type => $content ) {
620 $type = sanitize_key( $type );
621 if ( ! in_array( $type, Snippet_Store::ALLOWED_TYPES, true ) ) {
622 continue;
623 }
624
625 $exec_all = $manifest[ $slug ]['execution'] ?? array();
626 $exec_one = ( is_array( $exec_all ) && isset( $exec_all[ $type ] ) && is_array( $exec_all[ $type ] ) ) ? $exec_all[ $type ] : array();
627 $execution_hook = isset( $exec_one['hook'] ) ? Utils::to_str( $exec_one['hook'] ) : '';
628 $validation = $this->validate_code( $type, Utils::to_str( $content ), $execution_hook );
629 if ( is_wp_error( $validation ) ) {
630 return new \WP_REST_Response( array( 'error' => $validation->get_error_message() ), 400 );
631 }
632
633 $filepath = $snippet_dir . '/snippet.' . $type;
634 if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
635 continue;
636 }
637
638 $hashes = $manifest[ $slug ]['file_hashes'] ?? array();
639 $hashes = is_array( $hashes ) ? $hashes : array();
640 $prev_hash = $hashes[ $type ] ?? null;
641
642 $stored = 'php' === $type
643 ? Snippet_Store::prepare_php_file( Utils::to_str( $content ), Utils::to_str( $manifest[ $slug ]['title'] ?? '' ) )
644 : Utils::to_str( $content );
645 if ( is_wp_error( $stored ) ) {
646 return new \WP_REST_Response( array( 'error' => $stored->get_error_message() ), 400 );
647 }
648 Snippet_Store::put_file( $filepath, $stored );
649 Snippet_Store::invalidate_opcache( $filepath );
650
651 // Update hash + files list.
652 $new_hash = hash_file( 'sha256', $filepath );
653 $hashes[ $type ] = $new_hash;
654 $manifest[ $slug ]['file_hashes'] = $hashes;
655
656 if ( $new_hash !== $prev_hash ) {
657 $content_changed = true;
658 $snapshot_needed = true;
659 }
660
661 $files_list = $manifest[ $slug ]['files'] ?? array();
662 $files_list = is_array( $files_list ) ? $files_list : array();
663 if ( ! in_array( $type, $files_list, true ) ) {
664 $files_list[] = $type;
665 $manifest[ $slug ]['files'] = $files_list;
666 }
667 }
668 if ( $content_changed ) {
669 $applied[] = 'file_contents';
670 }
671 }
672
673 $manifest[ $slug ]['updated'] = gmdate( 'Y-m-d H:i:s' );
674 $manifest[ $slug ]['updated_by'] = get_current_user_id();
675 $manifest[ $slug ]['updated_by_type'] = 'user';
676
677 Snippet_Store::save( $manifest );
678
679 // Auto-snapshot new version when the saved snippet behavior changes.
680 // Versions include both files and snippet metadata, so rollback must
681 // cover targeting/execution/file-removal edits as well as code changes.
682 if ( $snapshot_needed ) {
683 Snippet_Versions::create(
684 $slug,
685 array(
686 'author_type' => 'user',
687 'author_id' => get_current_user_id(),
688 'author_name' => $this->current_user_display_name(),
689 'reason' => sanitize_text_field( Utils::to_str( $body['version_reason'] ?? '' ) ),
690 'manual' => false,
691 )
692 );
693 /**
694 * Narrowed type for `$manifest`.
695 *
696 * @var array<string, array<string, mixed>> $manifest
697 */
698 $manifest = Snippet_Store::load();
699 }
700
701 return new \WP_REST_Response(
702 array(
703 'success' => true,
704 'snippet' => $this->format_snippet( $slug, $manifest[ $slug ] ),
705 'applied_fields' => array_values( array_unique( $applied ) ),
706 ),
707 200
708 );
709 }
710
711 // ── Sandbox Mode ───────────────────────────────────────────────────
712
713 /**
714 * Toggle sandbox mode for a snippet.
715 *
716 * Actions:
717 * activate — enable sandbox for current user (snippet runs only for you)
718 * deactivate — turn off sandbox (snippet stops running)
719 * go_live — promote sandbox to live (enable for everyone, clear sandbox)
720 *
721 * @since 0.0.5
722 * @param \WP_REST_Request $request REST request.
723 * @return \WP_REST_Response Response with the sandbox and enabled state or an error.
724 */
725 public function toggle_sandbox( $request ) {
726 $slug = Snippet_Store::validate_slug( Utils::to_str( $request->get_param( 'slug' ) ) );
727 if ( ! $slug ) {
728 return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
729 }
730
731 /**
732 * Narrowed type for `$manifest`.
733 *
734 * @var array<string, array<string, mixed>> $manifest
735 */
736 $manifest = Snippet_Store::load();
737 if ( ! isset( $manifest[ $slug ] ) ) {
738 return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
739 }
740
741 $body = $request->get_json_params();
742 $action = $body['action'] ?? 'activate';
743
744 switch ( $action ) {
745 case 'activate':
746 // No subprocess test needed — just enable sandbox for this user.
747 // The executor will include the file ONLY for this user.
748 // If it crashes, only their session is affected.
749 $manifest[ $slug ]['sandbox'] = true;
750 $manifest[ $slug ]['sandbox_user'] = get_current_user_id();
751 $manifest[ $slug ]['enabled'] = false; // Not live — only sandbox.
752 break;
753
754 case 'deactivate':
755 $manifest[ $slug ]['sandbox'] = false;
756 $manifest[ $slug ]['sandbox_user'] = null;
757 break;
758
759 case 'go_live':
760 // Promote from sandbox to live — you already tested it in sandbox.
761 $manifest[ $slug ]['sandbox'] = false;
762 $manifest[ $slug ]['sandbox_user'] = null;
763 $manifest[ $slug ]['enabled'] = true;
764 $manifest[ $slug ]['auto_disabled'] = false;
765 $manifest[ $slug ]['disabled_reason'] = null;
766 break;
767
768 default:
769 return new \WP_REST_Response( array( 'error' => 'Invalid action.' ), 400 );
770 }
771
772 Snippet_Store::save( $manifest );
773
774 return new \WP_REST_Response(
775 array(
776 'success' => true,
777 'sandbox' => $manifest[ $slug ]['sandbox'],
778 'enabled' => $manifest[ $slug ]['enabled'],
779 ),
780 200
781 );
782 }
783
784 // ── Safe Mode ──────────────────────────────────────────────────────
785
786 /**
787 * Toggle, activate, or deactivate snippet safe mode.
788 *
789 * @param \WP_REST_Request $request REST request.
790 * @return \WP_REST_Response Response with the safe-mode state.
791 */
792 public function toggle_safe_mode( $request ) {
793 $body = $request->get_json_params();
794 $action = $body['action'] ?? 'toggle';
795 $duration = min( max( intval( Utils::to_str( $body['duration'] ?? 30 ) ), 1 ), 1440 ); // 1 min to 24 hours, default 30 min.
796 $ttl = $duration * MINUTE_IN_SECONDS;
797
798 if ( 'activate' === $action ) {
799 set_transient( 'zip_ai_snippets_safe_mode', time() + $ttl, $ttl );
800 return new \WP_REST_Response(
801 array(
802 'success' => true,
803 'active' => true,
804 'duration' => $duration,
805 ),
806 200
807 );
808 }
809
810 // Leaving safe mode lets every enabled snippet run again — write-level
811 // authority, same as enabling one. Entering it is the panic switch and
812 // stays on the read cap.
813 $is_active = (bool) get_transient( 'zip_ai_snippets_safe_mode' );
814 if ( 'deactivate' === $action || ( 'toggle' === $action && $is_active ) ) {
815 $allowed = $this->check_write_permission();
816 if ( is_wp_error( $allowed ) ) {
817 return new \WP_REST_Response( array( 'error' => $allowed->get_error_message() ), 403 );
818 }
819 }
820
821 if ( 'deactivate' === $action ) {
822 delete_transient( 'zip_ai_snippets_safe_mode' );
823 return new \WP_REST_Response(
824 array(
825 'success' => true,
826 'active' => false,
827 ),
828 200
829 );
830 }
831
832 // Toggle.
833 if ( $is_active ) {
834 delete_transient( 'zip_ai_snippets_safe_mode' );
835 } else {
836 set_transient( 'zip_ai_snippets_safe_mode', time() + $ttl, $ttl );
837 }
838
839 return new \WP_REST_Response(
840 array(
841 'success' => true,
842 'active' => ! $is_active,
843 'duration' => $duration,
844 ),
845 200
846 );
847 }
848
849 // ── Toggle ─────────────────────────────────────────────────────────
850
851 /**
852 * Enable or disable a snippet.
853 *
854 * @param \WP_REST_Request $request REST request.
855 * @return \WP_REST_Response Response with the enabled state or an error.
856 */
857 public function toggle_snippet( $request ) {
858 $slug = Snippet_Store::validate_slug( Utils::to_str( $request->get_param( 'slug' ) ) );
859 if ( ! $slug ) {
860 return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
861 }
862
863 /**
864 * Narrowed type for `$manifest`.
865 *
866 * @var array<string, array<string, mixed>> $manifest
867 */
868 $manifest = Snippet_Store::load();
869 if ( ! isset( $manifest[ $slug ] ) ) {
870 return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
871 }
872
873 $will_enable = ! $manifest[ $slug ]['enabled'];
874
875 // Turning a snippet ON makes its PHP run, which is write-level authority.
876 // Turning it OFF is the safety valve and stays on the read cap.
877 if ( $will_enable ) {
878 $allowed = $this->check_write_permission();
879 if ( is_wp_error( $allowed ) ) {
880 return new \WP_REST_Response( array( 'error' => $allowed->get_error_message() ), 403 );
881 }
882 }
883
884 $manifest[ $slug ]['enabled'] = $will_enable;
885
886 if ( $will_enable ) {
887 $manifest[ $slug ]['auto_disabled'] = false;
888 $manifest[ $slug ]['disabled_reason'] = null;
889 $manifest[ $slug ]['disabled_at'] = null;
890 }
891
892 Snippet_Store::save( $manifest );
893
894 return new \WP_REST_Response(
895 array(
896 'success' => true,
897 'enabled' => $manifest[ $slug ]['enabled'],
898 ),
899 200
900 );
901 }
902
903 // ── Delete ─────────────────────────────────────────────────────────
904
905 /**
906 * Delete a snippet and its on-disk directory.
907 *
908 * @param \WP_REST_Request $request REST request.
909 * @return \WP_REST_Response Response indicating success or an error.
910 */
911 public function delete_snippet( $request ) {
912 $slug = Snippet_Store::validate_slug( Utils::to_str( $request->get_param( 'slug' ) ) );
913 if ( ! $slug ) {
914 return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
915 }
916
917 // Pre-flight existence check using an un-locked read. The
918 // authoritative existence check runs again under the lock below.
919 /**
920 * Narrowed type for `$pre`.
921 *
922 * @var array<string, array<string, mixed>> $pre
923 */
924 $pre = Snippet_Store::load();
925 if ( ! isset( $pre[ $slug ] ) ) {
926 return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
927 }
928
929 // Remove the on-disk snippet directory BEFORE touching the manifest
930 // so that on a crash between rmdir and save() the manifest still
931 // describes a real directory (worst case: a stale entry pointing at
932 // a vanished dir, which the loader / runtime already tolerate). The
933 // inverse order — manifest first, files second — could orphan files
934 // the manifest no longer knows about.
935 $snippet_dir = Snippet_Store::snippet_dir( $slug );
936 if ( is_dir( $snippet_dir ) ) {
937 $this->remove_directory( $snippet_dir );
938 }
939
940 // Atomically drop the slug from the manifest. Lock spans the full
941 // read-modify-write window, so parallel bulk-delete requests cannot
942 // each load the same baseline and overwrite each other's deletions.
943 Snippet_Store::with_lock(
944 /**
945 * Drop the target slug from the loaded manifest.
946 *
947 * @param array<string, mixed> $manifest
948 */
949 static function ( array &$manifest ) use ( $slug ) {
950 unset( $manifest[ $slug ] );
951 }
952 );
953
954 return new \WP_REST_Response( array( 'success' => true ), 200 );
955 }
956
957 // ── Export ─────────────────────────────────────────────────────────
958
959 /**
960 * Export selected snippets as JSON.
961 *
962 * @since 0.0.5
963 * @param \WP_REST_Request $request REST request.
964 * @return \WP_REST_Response Response with the exported snippets payload.
965 */
966 public function export_snippets( $request ) {
967 $slugs_param = $request->get_param( 'slugs' );
968 /**
969 * Narrowed type for `$manifest`.
970 *
971 * @var array<string, array<string, mixed>> $manifest
972 */
973 $manifest = Snippet_Store::load();
974 $export = array();
975
976 $slugs = $slugs_param ? array_map( 'sanitize_key', explode( ',', Utils::to_str( $slugs_param ) ) ) : array_keys( $manifest );
977
978 foreach ( $slugs as $slug ) {
979 if ( '_version' === $slug || ! isset( $manifest[ $slug ] ) ) {
980 continue;
981 }
982
983 $meta = Snippet_Store::normalize_snippet( $manifest[ $slug ] );
984 $files = array();
985
986 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
987 $filepath = Snippet_Store::snippet_file( $slug, $type );
988 if ( file_exists( $filepath ) ) {
989 $raw = (string) file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
990 $files[ $type ] = base64_encode( 'php' === $type ? Snippet_Store::unwrap_php( $raw ) : $raw ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
991 }
992 }
993
994 $export[] = array(
995 'slug' => $slug,
996 'title' => $meta['title'],
997 'description' => $meta['description'],
998 'execution' => $meta['execution'],
999 'conditions' => $meta['conditions'],
1000 'files' => $files,
1001 );
1002 }
1003
1004 return new \WP_REST_Response(
1005 array(
1006 'version' => 1,
1007 'exported_at' => gmdate( 'c' ),
1008 'snippets' => $export,
1009 ),
1010 200
1011 );
1012 }
1013
1014 // ── Import ─────────────────────────────────────────────────────────
1015
1016 /**
1017 * Import snippets from JSON.
1018 *
1019 * @since 0.0.5
1020 * @param \WP_REST_Request $request REST request.
1021 * @return \WP_REST_Response Response with per-snippet import results.
1022 */
1023 public function import_snippets( $request ) {
1024 $body = $request->get_json_params();
1025 $preview = ! empty( $request->get_param( 'preview' ) );
1026
1027 if ( empty( $body['snippets'] ) || ! is_array( $body['snippets'] ) ) {
1028 return new \WP_REST_Response( array( 'error' => 'Invalid import format. Expected { snippets: [...] }' ), 400 );
1029 }
1030
1031 /**
1032 * Narrowed type for `$manifest`.
1033 *
1034 * @var array<string, array<string, mixed>> $manifest
1035 */
1036 $manifest = Snippet_Store::load();
1037 $results = array();
1038
1039 foreach ( $body['snippets'] as $item ) {
1040 /**
1041 * Narrowed type for `$item`.
1042 *
1043 * @var array<string, mixed> $item
1044 */
1045 $slug = sanitize_title( Utils::to_str( $item['slug'] ?? $item['title'] ?? '' ) );
1046 $slug = Snippet_Store::validate_slug( $slug );
1047
1048 if ( ! $slug ) {
1049 $results[] = array(
1050 'slug' => $item['slug'] ?? '?',
1051 'success' => false,
1052 'error' => 'Invalid slug.',
1053 );
1054 continue;
1055 }
1056
1057 // Handle conflicts.
1058 $original = $slug;
1059 $counter = 1;
1060 while ( isset( $manifest[ $slug ] ) ) {
1061 $slug = $original . '-imported-' . $counter;
1062 ++$counter;
1063 }
1064
1065 if ( $preview ) {
1066 /**
1067 * Narrowed type for `$preview_files`.
1068 *
1069 * @var array<string, mixed> $preview_files
1070 */
1071 $preview_files = $item['files'] ?? array();
1072 $results[] = array(
1073 'slug' => $slug,
1074 'title' => $item['title'] ?? $slug,
1075 'files' => array_keys( $preview_files ),
1076 'conflict' => $slug !== $original,
1077 'will_create' => true,
1078 );
1079 continue;
1080 }
1081
1082 $execution = $this->sanitize_execution( $item['execution'] ?? array() );
1083 /**
1084 * Narrowed type for `$import_files`.
1085 *
1086 * @var array<string, mixed> $import_files
1087 */
1088 $import_files = $item['files'] ?? array();
1089
1090 // Decode + validate EVERY file before touching the filesystem. A
1091 // mid-loop bail after mkdir would orphan a randomly-named directory
1092 // the UI can neither see nor delete, one per retry.
1093 $bodies = array();
1094 $item_failed = false;
1095 foreach ( $import_files as $type => $encoded ) {
1096 $type = sanitize_key( $type );
1097 if ( ! in_array( $type, Snippet_Store::ALLOWED_TYPES, true ) ) {
1098 continue;
1099 }
1100
1101 $content = base64_decode( Utils::to_str( $encoded ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1102
1103 // An exported pack carries the stored wrapper; lint the author's
1104 // body, not our guard header.
1105 if ( 'php' === $type ) {
1106 $content = Snippet_Store::unwrap_php( $content );
1107 }
1108
1109 $validation = $this->validate_code( $type, $content, Utils::to_str( $execution[ $type ]['hook'] ?? '' ) );
1110 if ( is_wp_error( $validation ) ) {
1111 $results[] = array(
1112 'slug' => $slug,
1113 'success' => false,
1114 'error' => $validation->get_error_message(),
1115 );
1116 $item_failed = true;
1117 break;
1118 }
1119
1120 if ( 'php' === $type ) {
1121 $stored = Snippet_Store::prepare_php_file( $content, Utils::to_str( $item['title'] ?? $slug ) );
1122 if ( is_wp_error( $stored ) ) {
1123 $results[] = array(
1124 'slug' => $slug,
1125 'success' => false,
1126 'error' => $stored->get_error_message(),
1127 );
1128 $item_failed = true;
1129 break;
1130 }
1131 $bodies[ $type ] = $stored;
1132 continue;
1133 }
1134
1135 $bodies[ $type ] = $content;
1136 }
1137
1138 if ( $item_failed ) {
1139 continue;
1140 }
1141
1142 // Randomized on-disk dir + HTTP-deny protection files. The slug
1143 // comes from the import payload, so a slug-named dir would be an
1144 // attacker-chosen, directly fetchable path.
1145 $dir_name = Snippet_Store::prepare_snippet_dir( $slug, true );
1146 $snippet_dir = Snippet_Store::snippet_dir( $slug );
1147
1148 // Write files. `$bodies` already holds storage-ready bytes — PHP was
1149 // wrapped and parse-checked in the validation pass above.
1150 $files = array();
1151 $file_hashes = array();
1152 foreach ( $bodies as $type => $stored ) {
1153 $filepath = $snippet_dir . '/snippet.' . $type;
1154 if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
1155 $results[] = array(
1156 'slug' => $slug,
1157 'success' => false,
1158 'error' => 'Refused unsafe write path.',
1159 );
1160 $item_failed = true;
1161 break;
1162 }
1163 Snippet_Store::put_file( $filepath, $stored );
1164 Snippet_Store::invalidate_opcache( $filepath );
1165 $files[] = $type;
1166 $file_hashes[ $type ] = hash_file( 'sha256', $filepath );
1167 }
1168
1169 if ( $item_failed ) {
1170 // Nothing references this dir — import always resolves to a
1171 // fresh slug, so it was minted a few lines ago and has no
1172 // manifest entry. Drop it rather than leak it.
1173 $this->remove_directory( $snippet_dir );
1174 continue;
1175 }
1176
1177 // Build manifest entry — always disabled on import. Rejected
1178 // condition rows don't block the import (the snippet arrives
1179 // DISABLED, so nothing can widen silently) but are surfaced on
1180 // the per-item result for review before enabling.
1181 $item_conditions = $this->sanitize_conditions( $item['conditions'] ?? array() );
1182
1183 $manifest[ $slug ] = array(
1184 'title' => sanitize_text_field( Utils::to_str( $item['title'] ?? $slug ) ),
1185 'dir' => $dir_name, // randomized on-disk basename
1186 'description' => sanitize_text_field( Utils::to_str( $item['description'] ?? '' ) ),
1187 'files' => $files,
1188 'file_hashes' => $file_hashes,
1189 'enabled' => false,
1190 'execution' => $execution,
1191 'conditions' => $item_conditions['rows'],
1192 'created' => gmdate( 'Y-m-d H:i:s' ),
1193 'created_by' => get_current_user_id(),
1194 'updated' => gmdate( 'Y-m-d H:i:s' ),
1195 'updated_by' => get_current_user_id(),
1196 );
1197
1198 $result_row = array(
1199 'slug' => $slug,
1200 'success' => true,
1201 );
1202 if ( ! empty( $item_conditions['rejected'] ) ) {
1203 $result_row['condition_warnings'] = $item_conditions['rejected'];
1204 }
1205 $results[] = $result_row;
1206 }
1207
1208 if ( ! $preview ) {
1209 Snippet_Store::save( $manifest );
1210 }
1211
1212 return new \WP_REST_Response(
1213 array(
1214 'success' => true,
1215 'preview' => $preview,
1216 'results' => $results,
1217 ),
1218 200
1219 );
1220 }
1221
1222 // ── Targeting lookups ──────────────────────────────────────────────
1223
1224 /**
1225 * Search posts/pages by title for the post-picker condition. Returns up to
1226 * 20 matches across publish + draft (the caller filters status client-side
1227 * if needed).
1228 *
1229 * @param \WP_REST_Request $request REST request.
1230 * @return \WP_REST_Response Response with matched post/page results.
1231 */
1232 public function lookup_posts( $request ) {
1233 $q = sanitize_text_field( Utils::to_str( $request->get_param( 'q' ) ) );
1234 if ( strlen( $q ) < 1 ) {
1235 return new \WP_REST_Response( array( 'results' => array() ), 200 );
1236 }
1237
1238 $post_types = get_post_types( array( 'public' => true ), 'names' );
1239 $query = new \WP_Query(
1240 array(
1241 'post_type' => array_values( $post_types ),
1242 'post_status' => array( 'publish', 'draft', 'private' ),
1243 'perm' => 'readable',
1244 's' => $q,
1245 'posts_per_page' => 20,
1246 'orderby' => 'date',
1247 'order' => 'DESC',
1248 'no_found_rows' => true,
1249 )
1250 );
1251
1252 $results = array();
1253 foreach ( $query->posts as $post ) {
1254 if ( ! $post instanceof \WP_Post ) {
1255 continue;
1256 }
1257 $results[] = array(
1258 'id' => (int) $post->ID,
1259 'title' => '' !== $post->post_title ? $post->post_title : sprintf( '(no title #%d)', $post->ID ),
1260 'type' => $post->post_type,
1261 'slug' => $post->post_name,
1262 );
1263 }
1264
1265 return new \WP_REST_Response( array( 'results' => $results ), 200 );
1266 }
1267
1268 // ── Versions ───────────────────────────────────────────────────────
1269
1270 /**
1271 * Current user's display name, or "You" when unavailable.
1272 *
1273 * @return string Display name.
1274 */
1275 private function current_user_display_name() {
1276 $user = wp_get_current_user();
1277 return $user->ID ? $user->display_name : 'You';
1278 }
1279
1280 /**
1281 * Resolve and validate the slug param, ensuring the snippet exists.
1282 *
1283 * @param \WP_REST_Request $request REST request.
1284 * @return string|\WP_REST_Response Validated slug, or an error response.
1285 */
1286 private function require_snippet( $request ) {
1287 $raw = Utils::to_str( $request->get_param( 'slug' ) );
1288 $slug = Snippet_Store::validate_slug( $raw );
1289 if ( ! $slug ) {
1290 return new \WP_REST_Response(
1291 array( 'error' => sprintf( 'Invalid slug "%s". Slugs must contain only letters, numbers, and hyphens.', $raw ) ),
1292 400
1293 );
1294 }
1295 $manifest = Snippet_Store::load();
1296 if ( ! isset( $manifest[ $slug ] ) ) {
1297 return new \WP_REST_Response(
1298 array( 'error' => Snippet_Store::not_found_message( $slug ) ),
1299 404
1300 );
1301 }
1302 return $slug;
1303 }
1304
1305 /**
1306 * List the version history for a snippet.
1307 *
1308 * @param \WP_REST_Request $request REST request.
1309 * @return \WP_REST_Response Response with the versions list or an error.
1310 */
1311 public function list_versions( $request ) {
1312 $slug = $this->require_snippet( $request );
1313 if ( ! is_string( $slug ) ) {
1314 return $slug;
1315 }
1316 return new \WP_REST_Response(
1317 array(
1318 'success' => true,
1319 'versions' => Snippet_Versions::list_versions( $slug ),
1320 ),
1321 200
1322 );
1323 }
1324
1325 /**
1326 * Get a single stored version of a snippet.
1327 *
1328 * @param \WP_REST_Request $request REST request.
1329 * @return \WP_REST_Response Response with the version or an error.
1330 */
1331 public function get_version( $request ) {
1332 $slug = $this->require_snippet( $request );
1333 if ( ! is_string( $slug ) ) {
1334 return $slug;
1335 }
1336 $version = Snippet_Versions::get( $slug, Utils::to_str( $request->get_param( 'id' ) ) );
1337 if ( is_wp_error( $version ) ) {
1338 $status = $version->get_error_code() === 'version_not_found' ? 404 : 400;
1339 return new \WP_REST_Response(
1340 array(
1341 'error' => $version->get_error_message(),
1342 'code' => $version->get_error_code(),
1343 ),
1344 $status
1345 );
1346 }
1347 return new \WP_REST_Response( array( 'success' => true ) + $version, 200 );
1348 }
1349
1350 /**
1351 * Diff a version against another version or current HEAD.
1352 *
1353 * @param \WP_REST_Request $request REST request.
1354 * @return \WP_REST_Response Response with the computed diff.
1355 */
1356 public function diff_version( $request ) {
1357 $slug = $this->require_snippet( $request );
1358 if ( ! is_string( $slug ) ) {
1359 return $slug;
1360 }
1361 $against = $request->get_param( 'against' );
1362 $against = $against ? Utils::to_str( $against ) : null;
1363 $diff = Snippet_Versions::diff( $slug, Utils::to_str( $request->get_param( 'id' ) ), $against );
1364 return new \WP_REST_Response(
1365 array(
1366 'success' => true,
1367 'diff' => $diff,
1368 ),
1369 200
1370 );
1371 }
1372
1373 /**
1374 * Create a manual version snapshot for a snippet.
1375 *
1376 * @param \WP_REST_Request $request REST request.
1377 * @return \WP_REST_Response Response with the created version or an error.
1378 */
1379 public function create_manual_version( $request ) {
1380 $slug = $this->require_snippet( $request );
1381 if ( ! is_string( $slug ) ) {
1382 return $slug;
1383 }
1384 $body = $request->get_json_params();
1385 $reason = sanitize_text_field( Utils::to_str( $body['reason'] ?? '' ) );
1386 if ( '' === $reason ) {
1387 return new \WP_REST_Response( array( 'error' => 'reason is required.' ), 400 );
1388 }
1389 $entry = Snippet_Versions::create(
1390 $slug,
1391 array(
1392 'author_type' => 'user',
1393 'author_id' => get_current_user_id(),
1394 'author_name' => $this->current_user_display_name(),
1395 'reason' => $reason,
1396 'manual' => true,
1397 )
1398 );
1399 if ( ! $entry ) {
1400 return new \WP_REST_Response( array( 'error' => 'Failed to create version.' ), 500 );
1401 }
1402 return new \WP_REST_Response(
1403 array(
1404 'success' => true,
1405 'version' => $entry,
1406 ),
1407 201
1408 );
1409 }
1410
1411 /**
1412 * Restore a snippet to a stored version.
1413 *
1414 * @param \WP_REST_Request $request REST request.
1415 * @return \WP_REST_Response Response with the restore result and any lint warnings.
1416 */
1417 public function restore_version( $request ) {
1418 $slug = $this->require_snippet( $request );
1419 if ( ! is_string( $slug ) ) {
1420 return $slug;
1421 }
1422 $entry = Snippet_Versions::restore(
1423 $slug,
1424 Utils::to_str( $request->get_param( 'id' ) ),
1425 array(
1426 'author_type' => 'user',
1427 'author_id' => get_current_user_id(),
1428 'author_name' => $this->current_user_display_name(),
1429 )
1430 );
1431 if ( is_wp_error( $entry ) ) {
1432 return new \WP_REST_Response(
1433 array(
1434 'error' => $entry->get_error_message(),
1435 'code' => $entry->get_error_code(),
1436 ),
1437 400
1438 );
1439 }
1440 if ( ! $entry ) {
1441 return new \WP_REST_Response( array( 'error' => 'Failed to restore version.' ), 500 );
1442 }
1443 // Restore is a deliberate escape hatch — old code may pre-date current
1444 // lint rules. Surface warnings in the response so the caller can act
1445 // on them, but never block the restore itself.
1446 $lint_warnings = $this->lint_head_php( $slug );
1447 return new \WP_REST_Response(
1448 array(
1449 'success' => true,
1450 'version' => $entry,
1451 'lint_warnings' => $lint_warnings,
1452 ),
1453 200
1454 );
1455 }
1456
1457 /**
1458 * Run lint on the current HEAD PHP file of a snippet (post-restore).
1459 * Returns an array of warning records or an empty array when clean.
1460 *
1461 * @param string $slug Snippet slug.
1462 * @return array<int,array{code:string,message:string}> Lint warning records, empty when clean.
1463 */
1464 private function lint_head_php( $slug ) {
1465 /**
1466 * Narrowed type for `$manifest`.
1467 *
1468 * @var array<string, array<string, mixed>> $manifest
1469 */
1470 $manifest = Snippet_Store::load();
1471 if ( ! isset( $manifest[ $slug ] ) ) {
1472 return array();
1473 }
1474 /**
1475 * Narrowed type for `$files`.
1476 *
1477 * @var list<string> $files
1478 */
1479 $files = $manifest[ $slug ]['files'] ?? array();
1480 if ( ! in_array( 'php', $files, true ) ) {
1481 return array();
1482 }
1483 $file = Snippet_Store::snippet_file( $slug, 'php' );
1484 if ( ! file_exists( $file ) ) {
1485 return array();
1486 }
1487 $code = Snippet_Store::unwrap_php( (string) file_get_contents( $file ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1488 $exec_all = $manifest[ $slug ]['execution'] ?? array();
1489 $exec_php = ( is_array( $exec_all ) && isset( $exec_all['php'] ) && is_array( $exec_all['php'] ) ) ? $exec_all['php'] : array();
1490 $execution_hook = isset( $exec_php['hook'] ) ? Utils::to_str( $exec_php['hook'] ) : '';
1491 $result = Snippet_Lint::check( (string) $code, $execution_hook );
1492 if ( ! is_wp_error( $result ) ) {
1493 return array();
1494 }
1495 return array(
1496 array(
1497 'code' => (string) $result->get_error_code(),
1498 'message' => $result->get_error_message(),
1499 ),
1500 );
1501 }
1502
1503 /**
1504 * Delete a stored version of a snippet.
1505 *
1506 * @param \WP_REST_Request $request REST request.
1507 * @return \WP_REST_Response Response indicating success or an error.
1508 */
1509 public function delete_version( $request ) {
1510 $slug = $this->require_snippet( $request );
1511 if ( ! is_string( $slug ) ) {
1512 return $slug;
1513 }
1514 $ok = Snippet_Versions::delete( $slug, Utils::to_str( $request->get_param( 'id' ) ) );
1515 if ( is_wp_error( $ok ) ) {
1516 $status = $ok->get_error_code() === 'version_not_found' ? 404 : 400;
1517 return new \WP_REST_Response(
1518 array(
1519 'error' => $ok->get_error_message(),
1520 'code' => $ok->get_error_code(),
1521 ),
1522 $status
1523 );
1524 }
1525 return new \WP_REST_Response( array( 'success' => true ), 200 );
1526 }
1527
1528 // ── Validation ─────────────────────────────────────────────────────
1529
1530 /**
1531 * Validate code content (size limit + PHP blocklist).
1532 *
1533 * @since 0.0.5
1534 * @param string $type File type.
1535 * @param string $content Code content.
1536 * @param string $execution_hook Execution hook name for lint context.
1537 * @return true|\WP_Error True when valid, error describing the problem otherwise.
1538 */
1539 private function validate_code( $type, $content, $execution_hook = '' ) {
1540 $content = str_replace( "\0", '', $content );
1541
1542 if ( strlen( $content ) > self::MAX_CODE_SIZE ) {
1543 return new \WP_Error( 'too_large', 'Code exceeds maximum size of 100KB.' );
1544 }
1545
1546 if ( 'php' === $type ) {
1547 $blocked = Snippet_Store::check_php_blocklist( $content );
1548 if ( $blocked ) {
1549 return new \WP_Error( 'blocked', "Blocked: '{$blocked}' is not allowed in PHP snippets." );
1550 }
1551
1552 // Inline-targeting lint — production gate that forces routing
1553 // checks into conditions[] instead of `if (is_home()) return;`.
1554 $lint = Snippet_Lint::check( $content, $execution_hook );
1555 if ( is_wp_error( $lint ) ) {
1556 return $lint;
1557 }
1558
1559 // Syntax check — prevents fatal errors on next page load.
1560 $syntax_error = $this->check_php_syntax( $content );
1561 if ( $syntax_error ) {
1562 return new \WP_Error( 'syntax_error', $syntax_error );
1563 }
1564 } else {
1565 // JS/CSS bodies must not contain HTML wrapper tags (the executor
1566 // already wraps them via wp_add_inline_script/style — an inner
1567 // `</script>`/`</style>` terminates the wrapper early). HTML
1568 // bodies must not contain `<?php`/`<?=` open tags (emitted via
1569 // echo, never executed). Same gate the agent ability runs.
1570 $non_php_lint = Snippet_Lint::check_non_php( $content, $type );
1571 if ( is_wp_error( $non_php_lint ) ) {
1572 return $non_php_lint;
1573 }
1574 }
1575
1576 return true;
1577 }
1578
1579 /**
1580 * Check PHP code for syntax errors using php -l.
1581 *
1582 * Writes code to a temp file, runs php -l, parses output.
1583 * Returns error message string on failure, null on success.
1584 *
1585 * @since 0.0.5
1586 * @param string $code PHP code to check.
1587 * @return string|null Error message or null if valid.
1588 */
1589 private function check_php_syntax( $code ) {
1590 // Delegate to the shared parse-only validator (token_get_all with
1591 // TOKEN_PARSE). It validates the body as a complete file and throws on
1592 // invalid syntax, without the if(false){...} eval wrapper's two failure
1593 // modes: false-positives on a correct body that closes the PHP tag and
1594 // ends in trailing HTML, and the risk of an eval parse error escaping
1595 // as a fatal that takes down the request.
1596 $error = Snippet_Lint::detect_syntax_error( $code );
1597 return null === $error ? null : 'PHP Syntax Error: ' . $error;
1598 }
1599
1600
1601 /**
1602 * Thin pass-through to the central Snippet_Store sanitizer. Kept as a
1603 * private method so call sites stay readable.
1604 *
1605 * @param mixed $execution Raw execution config keyed by file type.
1606 * @param mixed $persisted The snippet's persisted execution config — the merge baseline for partial updates.
1607 * @return array<string,array<string,mixed>> Sanitized execution config keyed by file type.
1608 */
1609 private function sanitize_execution( $execution, $persisted = null ) {
1610 /**
1611 * Narrowed type for `$exec_arr`.
1612 *
1613 * @var array<string, mixed> $exec_arr
1614 */
1615 $exec_arr = is_array( $execution ) ? $execution : array();
1616 /**
1617 * Narrowed type for `$sanitized`.
1618 *
1619 * @var array<string, array<string, mixed>> $sanitized
1620 */
1621 $sanitized = Snippet_Store::sanitize_execution( $exec_arr, is_array( $persisted ) ? $persisted : null );
1622 return $sanitized;
1623 }
1624
1625 /**
1626 * Thin pass-through to the central Snippet_Store conditions sanitizer.
1627 *
1628 * @param mixed $conditions Raw condition rows.
1629 * @return array{rows: array<int,array<string,mixed>>, rejected: array<int,string>} Sanitized rows + rejection reasons.
1630 */
1631 private function sanitize_conditions( $conditions ) {
1632 /**
1633 * Narrowed type for `$cond_arr`.
1634 *
1635 * @var array<int, mixed> $cond_arr
1636 */
1637 $cond_arr = is_array( $conditions ) ? $conditions : array();
1638 /**
1639 * Narrowed type for `$sanitized`.
1640 *
1641 * @var array{rows: array<int, array<string, mixed>>, rejected: array<int, string>} $sanitized
1642 */
1643 $sanitized = Snippet_Store::sanitize_conditions( $cond_arr );
1644 return $sanitized;
1645 }
1646
1647 // ── Helpers ─────────────────────────────────────────────────────────
1648
1649 /**
1650 * Build the API-facing snippet payload from a manifest entry.
1651 *
1652 * @param string $slug Snippet slug.
1653 * @param array<string,mixed> $meta Manifest entry for the snippet.
1654 * @return array<string,mixed> Formatted snippet fields for the response.
1655 */
1656 private function format_snippet( $slug, $meta ) {
1657 $normalized = Snippet_Store::normalize_snippet( $meta );
1658 /**
1659 * Narrowed type for `$manifest_files`.
1660 *
1661 * @var list<string> $manifest_files
1662 */
1663 $manifest_files = $normalized['files'];
1664 $disk_files = $this->scan_snippet_files( $slug );
1665 $all_files = array_values( array_unique( array_merge( $manifest_files, $disk_files ) ) );
1666
1667 return array(
1668 'slug' => $slug,
1669 'title' => $normalized['title'],
1670 'description' => $normalized['description'],
1671 'enabled' => $normalized['enabled'],
1672 'files' => $all_files,
1673 'execution' => $normalized['execution'],
1674 'conditions' => $normalized['conditions'],
1675 'sandbox' => $normalized['sandbox'],
1676 'sandbox_user' => $normalized['sandbox_user'],
1677 'auto_disabled' => $normalized['auto_disabled'],
1678 'disabled_reason' => $normalized['disabled_reason'],
1679 'disabled_at' => $normalized['disabled_at'],
1680 'created' => $normalized['created'],
1681 'updated' => $normalized['updated'],
1682 'updated_by_type' => $normalized['updated_by_type'],
1683 'current_version_id' => $normalized['current_version_id'],
1684 'versions_count' => $normalized['versions_count'],
1685 'last_version_at' => $normalized['last_version_at'],
1686 );
1687 }
1688
1689 /**
1690 * List the file types present on disk for a snippet.
1691 *
1692 * @param string $slug Snippet slug.
1693 * @return array<int,string> File-type extensions found on disk.
1694 */
1695 private function scan_snippet_files( $slug ) {
1696 $snippet_dir = Snippet_Store::snippet_dir( $slug );
1697 $types = array();
1698
1699 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
1700 $filepath = $snippet_dir . '/snippet.' . $type;
1701 if ( Snippet_Store::is_safe_path( $filepath ) && file_exists( $filepath ) ) {
1702 $types[] = $type;
1703 }
1704 }
1705
1706 return $types;
1707 }
1708
1709 /**
1710 * Read the on-disk contents of each snippet file.
1711 *
1712 * @param string $slug Snippet slug.
1713 * @return array<string,string> File-type extension mapped to file contents.
1714 */
1715 private function read_snippet_files( $slug ) {
1716 $contents = array();
1717
1718 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
1719 $filepath = Snippet_Store::snippet_file( $slug, $type );
1720 if ( Snippet_Store::is_safe_path( $filepath ) && file_exists( $filepath ) ) {
1721 $raw = (string) file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1722 $contents[ $type ] = 'php' === $type ? Snippet_Store::unwrap_php( $raw ) : $raw;
1723 }
1724 }
1725
1726 return $contents;
1727 }
1728
1729 /**
1730 * Recursively remove a snippet directory (guarded to the snippets base).
1731 *
1732 * @param string $dir Absolute directory path to remove.
1733 * @return void
1734 */
1735 private function remove_directory( $dir ) {
1736 $real_base = realpath( Snippet_Store::base_dir() );
1737 $real_dir = realpath( $dir );
1738 if ( ! $real_base || ! $real_dir || strpos( $real_dir, $real_base . DIRECTORY_SEPARATOR ) !== 0 ) {
1739 return;
1740 }
1741
1742 $files = new \RecursiveIteratorIterator(
1743 new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
1744 \RecursiveIteratorIterator::CHILD_FIRST
1745 );
1746
1747 foreach ( $files as $file ) {
1748 if ( ! $file instanceof \SplFileInfo ) {
1749 continue;
1750 }
1751 if ( $file->isDir() ) {
1752 Snippet_Store::delete_dir( (string) $file->getRealPath() );
1753 } else {
1754 wp_delete_file( (string) $file->getRealPath() );
1755 }
1756 }
1757
1758 Snippet_Store::delete_dir( $dir );
1759 }
1760 }
1761