PluginProbe
Code Snippets / 3.10.1
Code Snippets v3.10.1
3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 3.0.1 All 64 releases
code-snippets / php / Admin / Menus / Manage_Menu.php

Manage_Menu.php in Code Snippets 3.10.1, at php/Admin/Menus/Manage_Menu.php

600 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Code_Snippets\Admin\Menus;
4
5 use Code_Snippets\Admin\Contextual_Help;
6 use Code_Snippets\Migration\Export\Download_Code;
7 use Code_Snippets\Utils\Code_Highlighter;
8 use WP_Error;
9 use function Code_Snippets\code_snippets;
10 use function Code_Snippets\get_snippet;
11 use function Code_Snippets\get_snippets;
12 use function Code_Snippets\Settings\get_setting;
13 use const Code_Snippets\PLUGIN_FILE;
14 use const Code_Snippets\PLUGIN_VERSION;
15
16 /**
17 * This class handles the manage snippets menu
18 *
19 * @since 2.4.0
20 * @package Code_Snippets
21 */
22 class Manage_Menu extends Admin_Menu {
23
24 /**
25 * Handle for JavaScript asset file.
26 */
27 public const JS_HANDLE = 'code-snippets-manage-menu';
28
29 /**
30 * Handle for CSS asset file.
31 */
32 public const CSS_HANDLE = 'code-snippets-manage';
33
34 /**
35 * Default number of snippets shown per page in the manage table.
36 */
37 public const DEFAULT_SNIPPETS_PER_PAGE = 100;
38
39 /**
40 * Class constructor
41 */
42 public function __construct() {
43 parent::__construct(
44 'manage',
45 _x( 'All Snippets', 'menu label', 'code-snippets' ),
46 __( 'Snippets', 'code-snippets' )
47 );
48
49 if ( code_snippets()->is_compact_menu() ) {
50 add_action( 'admin_menu', array( $this, 'register_compact_menu' ), 2 );
51 add_action( 'network_admin_menu', array( $this, 'register_compact_menu' ), 2 );
52 }
53
54 add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 );
55 add_action( 'admin_init', array( $this, 'handle_bulk_download_request' ) );
56 add_action( 'admin_init', array( $this, 'save_truncation_preference' ) );
57 add_filter( 'set-screen-option', array( $this, 'save_screen_option' ), 10, 3 );
58 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_css' ) );
59 add_action( 'wp_ajax_update_code_snippet', array( $this, 'ajax_callback' ) );
60 }
61
62 /**
63 * Register the top-level 'Snippets' menu and associated 'Manage' subpage
64 */
65 public function register() {
66 add_menu_page(
67 __( 'Snippets', 'code-snippets' ),
68 _x( 'Snippets', 'top-level menu label', 'code-snippets' ),
69 code_snippets()->get_cap(),
70 code_snippets()->get_menu_slug(),
71 array( $this, 'render' ),
72 'none', // Added through CSS as a mask to prevent loading 'blinking'.
73 apply_filters( 'code_snippets/admin/menu_position', is_network_admin() ? 21 : 67 )
74 );
75
76 // Register the sub-menu.
77 parent::register();
78 }
79
80 /**
81 * Register the 'upgrade' menu item.
82 *
83 * @return void
84 */
85 public function register_upgrade_menu() {
86 if ( code_snippets()->licensing->is_licensed() || get_setting( 'general', 'hide_upgrade_menu' ) ) {
87 return;
88 }
89
90 $menu_title = sprintf(
91 '<span class="button button-primary code-snippets-upgrade-button">%s %s</span>',
92 _x( 'Go Pro', 'top-level menu label', 'code-snippets' ),
93 '<span class="dashicons dashicons-external" aria-hidden="true"></span>'
94 );
95
96 $hook = add_submenu_page(
97 code_snippets()->get_menu_slug(),
98 __( 'Upgrade to Pro', 'code-snippets' ),
99 $menu_title,
100 code_snippets()->get_cap(),
101 'code_snippets_upgrade',
102 '__return_empty_string',
103 100
104 );
105
106 add_action( "load-$hook", [ $this, 'load_upgrade_menu' ] );
107 }
108
109 /**
110 * Print CSS required for the admin menu icon.
111 *
112 * @return void
113 */
114 public function enqueue_menu_css() {
115 wp_enqueue_style(
116 'code-snippets-menu',
117 plugins_url( 'dist/menu.css', PLUGIN_FILE ),
118 [],
119 PLUGIN_VERSION
120 );
121 }
122
123 /**
124 * Redirect the user upon opening the upgrade menu.
125 *
126 * @return void
127 */
128 public function load_upgrade_menu() {
129 wp_safe_redirect( 'https://snipco.de/JE2f' );
130 exit;
131 }
132
133 /**
134 * Add menu pages for the compact menu
135 */
136 public function register_compact_menu() {
137
138 if ( ! code_snippets()->is_compact_menu() ) {
139 return;
140 }
141
142 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value is matched to known classes.
143 $sub = code_snippets()->get_menu_slug( isset( $_GET['sub'] ) ? sanitize_key( $_GET['sub'] ) : 'snippets' );
144
145 $classmap = array(
146 'snippets' => 'manage',
147 'add-snippet' => 'edit',
148 'edit-snippet' => 'edit',
149 'import-code-snippets' => 'import',
150 'snippets-settings' => 'settings',
151 );
152
153 $menus = code_snippets()->admin->menus;
154 $class = isset( $classmap[ $sub ], $menus[ $classmap[ $sub ] ] ) ? $menus[ $classmap[ $sub ] ] : $this;
155
156 /* Add a submenu to the Tools menu */
157 $hook = add_submenu_page(
158 'tools.php',
159 __( 'Snippets', 'code-snippets' ),
160 _x( 'Snippets', 'tools submenu label', 'code-snippets' ),
161 code_snippets()->get_cap(),
162 code_snippets()->get_menu_slug(),
163 array( $class, 'render' )
164 );
165
166 add_action( 'load-' . $hook, array( $class, 'load' ) );
167 }
168
169 /**
170 * Executed when the admin page is loaded.
171 */
172 public function load() {
173 parent::load();
174
175 $screen = get_current_screen();
176
177 if ( $screen && ! $this->is_cloud_community_view() ) {
178 add_filter( "manage_{$screen->id}_columns", array( $this, 'get_screen_columns' ) );
179 add_filter( 'screen_settings', array( $this, 'render_screen_settings' ), 10, 2 );
180 }
181
182 add_screen_option(
183 'per_page',
184 array(
185 'label' => __( 'Snippets per page', 'code-snippets' ),
186 'default' => $this->get_default_snippets_per_page(),
187 'option' => 'snippets_per_page',
188 )
189 );
190
191 $contextual_help = new Contextual_Help( 'edit' );
192 $contextual_help->load();
193 }
194
195 /**
196 * Enqueue scripts and stylesheets for the admin page.
197 */
198 public function enqueue_assets() {
199 $plugin = code_snippets();
200
201 wp_enqueue_style(
202 self::CSS_HANDLE,
203 plugins_url( 'dist/manage.css', PLUGIN_FILE ),
204 self::$style_deps,
205 PLUGIN_VERSION
206 );
207
208 wp_enqueue_script(
209 self::JS_HANDLE,
210 plugins_url( 'dist/manage.js', PLUGIN_FILE ),
211 self::$script_deps,
212 PLUGIN_VERSION,
213 [ 'in_footer' => true ]
214 );
215
216 Code_Highlighter::enqueue_all_prism_themes();
217
218 wp_set_script_translations( self::JS_HANDLE, 'code-snippets' );
219 $plugin->localize_script( self::JS_HANDLE );
220
221 $localized = [
222 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ),
223 'hiddenColumns' => $this->get_hidden_manage_columns(),
224 'truncateRowValues' => (int) $this->truncate_row_values(),
225 'snippetsPerPage' => $this->get_snippets_per_page(),
226 'cloudSearchPerPage' => $this->get_cloud_search_per_page(),
227 'isSafeModeActive' => code_snippets()->evaluate_functions->is_safe_mode_active(),
228 'bulkDownloadNonce' => wp_create_nonce( 'code_snippets_bulk_download' ),
229 'supportsZipDownloads' => class_exists( 'ZipArchive' ),
230 ];
231
232 // Only the manage-table view consumes the full snippets list; skip the
233 // table scan and inline payload on subpages that don't render the table.
234 if ( $this->is_manage_table_view() ) {
235 $localized['snippetsList'] = array_map(
236 function ( $snippet ) {
237 return $snippet->get_fields();
238 },
239 get_snippets()
240 );
241 }
242
243 wp_localize_script( self::JS_HANDLE, 'CODE_SNIPPETS_MANAGE', $localized );
244 }
245
246 /**
247 * Get the number of snippets to show per page.
248 *
249 * @return int
250 */
251 protected function get_snippets_per_page(): int {
252 $per_page = (int) get_user_option( 'snippets_per_page' );
253
254 if ( empty( $per_page ) || $per_page < 1 ) {
255 $per_page = $this->get_default_snippets_per_page();
256 }
257
258 return (int) apply_filters( 'snippets_per_page', $per_page );
259 }
260
261 /**
262 * Get the default number of snippets to show per page.
263 *
264 * @return int
265 */
266 protected function get_default_snippets_per_page(): int {
267 $default = (int) apply_filters( 'code_snippets/snippets_per_page_default', self::DEFAULT_SNIPPETS_PER_PAGE );
268
269 return max( 1, $default );
270 }
271
272 /**
273 * Get the number of snippets to show per page in the cloud search.
274 *
275 * The value defaults to the user's local snippets-per-page preference but can
276 * be overridden independently via the `code_snippets/cloud_search/per_page` filter.
277 *
278 * @return int
279 */
280 protected function get_cloud_search_per_page(): int {
281 return (int) apply_filters( 'code_snippets/cloud_search/per_page', $this->get_snippets_per_page() );
282 }
283
284 /**
285 * Render the snippets table interface.
286 *
287 * @return void
288 */
289 public function render() {
290 echo '<div id="manage-snippets-container" class="wrap"></div>';
291 }
292
293 /**
294 * Return the columns available in Screen Options for the snippets table.
295 *
296 * @param string[] $columns Existing columns.
297 *
298 * @return string[]
299 */
300 public function get_screen_columns( array $columns = array() ): array {
301 return array_merge(
302 $columns,
303 array(
304 '_title' => __( 'Columns', 'code-snippets' ),
305 'activate' => __( 'Active', 'code-snippets' ),
306 'name' => __( 'Name', 'code-snippets' ),
307 'type' => __( 'Type', 'code-snippets' ),
308 'desc' => __( 'Description', 'code-snippets' ),
309 'tags' => __( 'Tags', 'code-snippets' ),
310 'date' => __( 'Modified', 'code-snippets' ),
311 'priority' => __( 'Priority', 'code-snippets' ),
312 )
313 );
314 }
315
316 /**
317 * Get the list of columns hidden for the current user on the snippets screen.
318 *
319 * @return string[]
320 */
321 protected function get_hidden_manage_columns(): array {
322 $screen = get_current_screen();
323
324 return $screen ? get_hidden_columns( $screen ) : array();
325 }
326
327 /**
328 * Whether to truncate long row values in the snippets table.
329 *
330 * @return bool
331 */
332 protected function truncate_row_values(): bool {
333 $setting = get_user_option( 'snippets_table_truncate_row_values' );
334
335 return false === $setting ? true : (bool) $setting;
336 }
337
338 /**
339 * Read the current `subpage` routing parameter.
340 *
341 * @return string
342 */
343 protected function get_current_subpage(): string {
344 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing parameter.
345 return isset( $_REQUEST['subpage'] ) ? sanitize_key( wp_unslash( $_REQUEST['subpage'] ) ) : '';
346 }
347
348 /**
349 * Whether the current request renders the snippets-table view (the default subpage).
350 *
351 * @return bool
352 */
353 protected function is_manage_table_view(): bool {
354 return ! $this->get_current_subpage();
355 }
356
357 /**
358 * Whether the current manage subpage is the Community Cloud view.
359 *
360 * @return bool
361 */
362 protected function is_cloud_community_view(): bool {
363 return 'cloud-community' === $this->get_current_subpage();
364 }
365
366 /**
367 * Render extra Screen Options controls for the snippets table.
368 *
369 * @param string $screen_settings Existing screen settings HTML.
370 * @param \WP_Screen $screen Current screen object.
371 *
372 * @return string
373 */
374 public function render_screen_settings( string $screen_settings, \WP_Screen $screen ): string {
375 if ( $this->is_cloud_community_view() ) {
376 return $screen_settings;
377 }
378
379 ob_start();
380 ?>
381 <fieldset class="metabox-prefs table-options-prefs">
382 <legend><?php esc_html_e( 'Table Options', 'code-snippets' ); ?></legend>
383 <div class="metabox-prefs-container">
384 <label for="snippets-table-truncate-row-values">
385 <input
386 id="snippets-table-truncate-row-values"
387 name="snippets_table_truncate_row_values"
388 type="checkbox"
389 value="1"
390 <?php checked( $this->truncate_row_values() ); ?>
391 />
392 <?php esc_html_e( 'Truncate long snippet names and descriptions', 'code-snippets' ); ?>
393 </label>
394 </div>
395 </fieldset>
396 <?php
397
398 return $screen_settings . ob_get_clean();
399 }
400
401 /**
402 * Persist the snippets table truncation preference from Screen Options.
403 *
404 * @return void
405 */
406 public function save_truncation_preference(): void {
407 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified below before persisting the option.
408 if ( empty( $_POST['wp_screen_options'] ) || ! is_array( $_POST['wp_screen_options'] ) ) {
409 return;
410 }
411
412 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Sanitized and matched to a known admin page.
413 $page = isset( $_REQUEST['page'] ) ? sanitize_key( wp_unslash( $_REQUEST['page'] ) ) : '';
414
415 if ( code_snippets()->get_menu_slug() !== $page || ! current_user_can( code_snippets()->get_cap() ) ) {
416 return;
417 }
418
419 if ( $this->is_cloud_community_view() ) {
420 return;
421 }
422
423 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified here before persisting the option.
424 $nonce = isset( $_POST['screenoptionnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['screenoptionnonce'] ) ) : '';
425
426 if ( ! wp_verify_nonce( $nonce, 'screen-options-nonce' ) ) {
427 return;
428 }
429
430 update_user_option(
431 get_current_user_id(),
432 'snippets_table_truncate_row_values',
433 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified above before reading the checkbox state.
434 isset( $_POST['snippets_table_truncate_row_values'] ) ? 1 : 0
435 );
436 }
437
438 /**
439 * Handles saving the user's snippets per page preference
440 *
441 * @param mixed $status Current screen option status.
442 * @param string $option The screen option name.
443 * @param mixed $value Screen option value.
444 *
445 * @return mixed
446 */
447 public function save_screen_option( $status, string $option, $value ) {
448 return 'snippets_per_page' === $option ? $value : $status;
449 }
450
451 /**
452 * Handle bulk snippet code downloads from the manage screen.
453 *
454 * @return void
455 */
456 public function handle_bulk_download_request(): void {
457 if ( ! $this->is_bulk_download_request() ) {
458 return;
459 }
460
461 if ( ! current_user_can( code_snippets()->get_cap() ) ) {
462 $this->send_download_error( __( 'You are not allowed to download these snippets.', 'code-snippets' ), 403 );
463 return;
464 }
465
466 $nonce = filter_input( INPUT_POST, 'code_snippets_bulk_download_nonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS ) ?? '';
467
468 if ( ! wp_verify_nonce( $nonce, 'code_snippets_bulk_download' ) ) {
469 $this->send_download_error( __( 'The download request is no longer valid. Please refresh and try again.', 'code-snippets' ), 403 );
470 return;
471 }
472
473 $snippets = $this->get_requested_download_snippets();
474
475 if ( $snippets instanceof WP_Error ) {
476 $status = $snippets->get_error_data( 'status' );
477 $this->send_download_error( $snippets->get_error_message(), is_numeric( $status ) ? (int) $status : 403 );
478 return;
479 }
480
481 if ( empty( $snippets ) ) {
482 $this->send_download_error( __( 'No snippets were selected for download.', 'code-snippets' ) );
483 return;
484 }
485
486 $download = 1 === count( $snippets )
487 ? Download_Code::build_snippet_download( $snippets[0] )
488 : Download_Code::build_archive_download( $snippets );
489
490 if ( $download instanceof WP_Error ) {
491 $status = $download->get_error_data( 'status' );
492 $this->send_download_error( $download->get_error_message(), is_numeric( $status ) ? (int) $status : 500 );
493 return;
494 }
495
496 $this->send_download_response( $download );
497 }
498
499 /**
500 * Determine whether the current request is a bulk download request.
501 *
502 * @return bool
503 */
504 private function is_bulk_download_request(): bool {
505 $page = sanitize_key( filter_input( INPUT_GET, 'page' ) ?? '' );
506 $action = sanitize_key( filter_input( INPUT_POST, 'code_snippets_action' ) ?? '' );
507
508 return code_snippets()->get_menu_slug() === $page && 'bulk-download' === $action;
509 }
510
511 /**
512 * Resolve the snippets requested for download.
513 *
514 * @return array<\Code_Snippets\Model\Snippet>|WP_Error
515 */
516 private function get_requested_download_snippets() {
517 $snippets_json = wp_unslash( filter_input( INPUT_POST, 'snippets', FILTER_DEFAULT ) ?? '' );
518 $payload = '' === $snippets_json ? [] : json_decode( $snippets_json, true );
519
520 if ( ! is_array( $payload ) ) {
521 return [];
522 }
523
524 $snippets = [];
525
526 foreach ( $payload as $snippet_data ) {
527 if ( ! is_array( $snippet_data ) || empty( $snippet_data['id'] ) ) {
528 continue;
529 }
530
531 if ( ! empty( $snippet_data['network'] ) && ! current_user_can( code_snippets()->get_network_cap_name() ) ) {
532 return new WP_Error(
533 'code_snippets_forbidden_network_download',
534 __( 'You are not allowed to download network snippets.', 'code-snippets' ),
535 [ 'status' => 403 ]
536 );
537 }
538
539 $snippet = get_snippet(
540 absint( $snippet_data['id'] ),
541 ! empty( $snippet_data['network'] )
542 );
543
544 if ( $snippet->id ) {
545 $snippets[] = $snippet;
546 }
547 }
548
549 return $snippets;
550 }
551
552 /**
553 * Send a download file response and end execution.
554 *
555 * @param array{filename:string, content_type:string, content:string} $download Download data.
556 *
557 * @return void
558 */
559 private function send_download_response( array $download ): void {
560 while ( ob_get_level() ) {
561 ob_end_clean();
562 }
563
564 nocache_headers();
565 send_nosniff_header();
566
567 header( 'Content-Description: File Transfer' );
568 header( 'Content-Type: ' . $download['content_type'] );
569 header( 'Content-Disposition: attachment; filename="' . $download['filename'] . '"' );
570 header( 'Content-Length: ' . strlen( $download['content'] ) );
571 header( 'X-Suggested-Filename: ' . $download['filename'] );
572
573 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary download payload.
574 echo $download['content'];
575 exit;
576 }
577
578 /**
579 * Send a plain text download error response and end execution.
580 *
581 * @param string $message Error message.
582 * @param int $status HTTP status code.
583 *
584 * @return void
585 */
586 private function send_download_error( string $message, int $status = 400 ): void {
587 while ( ob_get_level() ) {
588 ob_end_clean();
589 }
590
591 status_header( $status );
592 nocache_headers();
593 send_nosniff_header();
594 header( 'Content-Type: text/plain; charset=' . get_option( 'blog_charset' ) );
595
596 echo esc_html( $message );
597 exit;
598 }
599 }
600