PluginProbe
Content Censor / trunk
Content Censor vtrunk
trunk 1.0 1.1 1.2 2.0 2.01 2.05 2.1 2.11 2.20 2.25 2.26 2.27 2.28 2.3 2.31 2.32 2.33 2.4 2.41 2.42 2.43 2.44 2.45 2.46 All 39 releases
wp-content-filter / includes / class-settings.php

class-settings.php in Content Censor trunk, at includes/class-settings.php

421 lines 16.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings repository and admin page.
4 *
5 * @package WPGO_Content_Censor
6 */
7
8 namespace WPGO\Content_Censor;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Owns the Settings API contract and accessible admin UI.
16 */
17 final class Settings {
18
19 /** Settings page slug. */
20 const PAGE_SLUG = 'content-censor-wpgoplugins';
21
22 /** Settings group. */
23 const GROUP = 'content_censor_plugin_options_group';
24
25 /**
26 * Settings page hook.
27 *
28 * @var string
29 */
30 private static $page_hook = '';
31
32 /**
33 * Register admin hooks.
34 *
35 * @return void
36 */
37 public static function boot() {
38 add_action( 'admin_init', array( __CLASS__, 'register' ) );
39 add_action( 'admin_menu', array( __CLASS__, 'add_page' ) );
40 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
41 add_action( 'admin_post_content_censor_reset', array( __CLASS__, 'reset' ) );
42 }
43
44 /**
45 * Return canonical defaults, including licensed extensions.
46 *
47 * @return array<string, string>
48 */
49 public static function defaults() {
50 $defaults = array(
51 'chk_post_title' => '1',
52 'chk_post_content' => '1',
53 'chk_comments' => '1',
54 'chk_tags' => '0',
55 'chk_tag_cloud' => '0',
56 'chk_ignore_html_attr' => '0',
57 'txtar_keywords' => 'Apple|Orange|Pear',
58 'txt_exclude' => '',
59 'drp_keyword_sep' => 'pipe',
60 'rdo_strict_filtering' => 'strict_on',
61 'rdo_word' => 'all',
62 'drp_filter_char' => 'star',
63 'rdo_case' => 'insen',
64 );
65
66 /**
67 * Filter the canonical option defaults.
68 *
69 * @param array<string, string> $defaults Defaults.
70 */
71 return apply_filters( 'content_censor_default_options', $defaults );
72 }
73
74 /**
75 * Return settings merged over defaults.
76 *
77 * @return array<string, string>
78 */
79 public static function get() {
80 $stored = get_option( WPGO_CONTENT_CENSOR_OPTIONS, array() );
81 return wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() );
82 }
83
84 /**
85 * Register the Settings API record.
86 *
87 * @return void
88 */
89 public static function register() {
90 register_setting(
91 self::GROUP,
92 WPGO_CONTENT_CENSOR_OPTIONS,
93 array(
94 'type' => 'array',
95 'sanitize_callback' => array( __CLASS__, 'sanitize' ),
96 'default' => self::defaults(),
97 )
98 );
99 }
100
101 /**
102 * Sanitize every supported option with an explicit allow-list.
103 *
104 * @param mixed $input Submitted value.
105 * @return array<string, string>
106 */
107 public static function sanitize( $input ) {
108 $input = is_array( $input ) ? $input : array();
109 $clean = array(
110 'txtar_keywords' => self::sanitize_keywords( (string) ( $input['txtar_keywords'] ?? '' ) ),
111 'txt_exclude' => Migrator::sanitize_id_list( (string) ( $input['txt_exclude'] ?? '' ) ),
112 'drp_keyword_sep' => self::allowed( $input, 'drp_keyword_sep', array( 'pipe', 'comma', 'pound' ), 'pipe' ),
113 'rdo_strict_filtering' => self::allowed( $input, 'rdo_strict_filtering', array( 'strict_on', 'strict_off' ), 'strict_on' ),
114 'rdo_word' => self::allowed( $input, 'rdo_word', array( 'first', 'all', 'firstlast' ), 'all' ),
115 'drp_filter_char' => self::allowed( $input, 'drp_filter_char', array( 'star', 'dollar', 'question', 'exclamation', 'hyphen', 'hash', 'tilde', 'blank' ), 'star' ),
116 'rdo_case' => self::allowed( $input, 'rdo_case', array( 'sen', 'insen' ), 'insen' ),
117 );
118
119 foreach ( array( 'chk_post_title', 'chk_post_content', 'chk_comments', 'chk_tags', 'chk_tag_cloud', 'chk_ignore_html_attr' ) as $checkbox ) {
120 $clean[ $checkbox ] = isset( $input[ $checkbox ] ) && '1' === (string) $input[ $checkbox ] ? '1' : '0';
121 }
122
123 // Keep licensed settings intact if premium code is temporarily unavailable,
124 // for example after a licence expires or while the Free build is active.
125 $stored = get_option( WPGO_CONTENT_CENSOR_OPTIONS, array() );
126 if ( is_array( $stored ) ) {
127 foreach ( array( 'chk_browser_title', 'txtar_filters' ) as $premium_key ) {
128 if ( array_key_exists( $premium_key, $stored ) ) {
129 $clean[ $premium_key ] = (string) $stored[ $premium_key ];
130 }
131 }
132 }
133
134 /**
135 * Sanitize licensed settings that are absent from the WordPress.org build.
136 *
137 * @param array<string, string> $clean Sanitized shared settings.
138 * @param array<string, mixed> $input Raw submitted settings.
139 */
140 return apply_filters( 'content_censor_sanitize_options', $clean, $input );
141 }
142
143 /**
144 * Add the Settings submenu.
145 *
146 * @return void
147 */
148 public static function add_page() {
149 self::$page_hook = (string) add_options_page(
150 esc_html__( 'Content Censor Settings', 'wp-content-filter' ),
151 esc_html__( 'Content Censor', 'wp-content-filter' ),
152 'manage_options',
153 self::PAGE_SLUG,
154 array( __CLASS__, 'render' )
155 );
156 }
157
158 /**
159 * Load assets only on this plugin's page.
160 *
161 * @param string $hook Current admin page hook.
162 * @return void
163 */
164 public static function enqueue_assets( $hook ) {
165 if ( self::$page_hook !== $hook ) {
166 return;
167 }
168 wp_enqueue_style(
169 'wpgo-content-censor-admin',
170 plugins_url( 'css/content-censor-admin.css', WPGO_CONTENT_CENSOR_FILE ),
171 array(),
172 WPGO_CONTENT_CENSOR_VERSION
173 );
174 }
175
176 /**
177 * Render the settings page.
178 *
179 * @return void
180 */
181 public static function render() {
182 if ( ! current_user_can( 'manage_options' ) ) {
183 wp_die( esc_html__( 'You do not have permission to manage Content Censor.', 'wp-content-filter' ) );
184 }
185
186 $tabs = apply_filters(
187 'content_censor_settings_tabs',
188 array(
189 'filtering' => esc_html__( 'Filtering', 'wp-content-filter' ),
190 'support' => esc_html__( 'License & Support', 'wp-content-filter' ),
191 )
192 );
193 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This selects a read-only settings tab.
194 $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'filtering';
195 if ( ! isset( $tabs[ $active_tab ] ) ) {
196 $active_tab = 'filtering';
197 }
198 ?>
199 <div class="wrap content-censor-settings">
200 <h1><?php esc_html_e( 'Content Censor', 'wp-content-filter' ); ?></h1>
201 <p class="content-censor-intro"><?php esc_html_e( 'Control where configured words are filtered and how each match is rendered.', 'wp-content-filter' ); ?></p>
202 <nav class="nav-tab-wrapper" aria-label="<?php esc_attr_e( 'Content Censor settings', 'wp-content-filter' ); ?>">
203 <?php foreach ( $tabs as $slug => $label ) : ?>
204 <a class="nav-tab <?php echo $active_tab === $slug ? 'nav-tab-active' : ''; ?>" href="<?php echo esc_url( self::tab_url( $slug ) ); ?>" <?php echo $active_tab === $slug ? 'aria-current="page"' : ''; ?>><?php echo esc_html( $label ); ?></a>
205 <?php endforeach; ?>
206 </nav>
207
208 <?php settings_errors(); ?>
209 <?php if ( 'filtering' === $active_tab ) : ?>
210 <?php self::render_filtering_form(); ?>
211 <?php elseif ( 'support' === $active_tab ) : ?>
212 <?php self::render_support(); ?>
213 <?php else : ?>
214 <?php do_action( 'content_censor_render_settings_tab', $active_tab ); ?>
215 <?php endif; ?>
216 </div>
217 <?php
218 }
219
220 /**
221 * Reset settings after a nonce and capability check.
222 *
223 * @return void
224 */
225 public static function reset() {
226 if ( ! current_user_can( 'manage_options' ) ) {
227 wp_die( esc_html__( 'You do not have permission to reset Content Censor.', 'wp-content-filter' ), 403 );
228 }
229 check_admin_referer( 'content_censor_reset' );
230 update_option( WPGO_CONTENT_CENSOR_OPTIONS, self::defaults(), false );
231 wp_safe_redirect( add_query_arg( 'settings-updated', 'reset', self::tab_url( 'filtering' ) ) );
232 exit;
233 }
234
235 /**
236 * Render shared settings.
237 *
238 * @return void
239 */
240 private static function render_filtering_form() {
241 $options = self::get();
242 $name = WPGO_CONTENT_CENSOR_OPTIONS;
243 ?>
244 <form method="post" action="options.php">
245 <?php settings_fields( self::GROUP ); ?>
246 <table class="form-table" role="presentation">
247 <tr>
248 <th scope="row"><label for="content-censor-keywords"><?php esc_html_e( 'Words to filter', 'wp-content-filter' ); ?></label></th>
249 <td>
250 <textarea class="large-text code" id="content-censor-keywords" name="<?php echo esc_attr( $name ); ?>[txtar_keywords]" rows="8"><?php echo esc_textarea( $options['txtar_keywords'] ); ?></textarea>
251 <p class="description"><?php esc_html_e( 'Enter words separated by the selected delimiter. Words shorter than three characters are ignored.', 'wp-content-filter' ); ?></p>
252 </td>
253 </tr>
254 <tr>
255 <th scope="row"><label for="content-censor-delimiter"><?php esc_html_e( 'Word delimiter', 'wp-content-filter' ); ?></label></th>
256 <td>
257 <select id="content-censor-delimiter" name="<?php echo esc_attr( $name ); ?>[drp_keyword_sep]">
258 <option value="pipe" <?php selected( 'pipe', $options['drp_keyword_sep'] ); ?>><?php esc_html_e( 'Pipe (|)', 'wp-content-filter' ); ?></option>
259 <option value="comma" <?php selected( 'comma', $options['drp_keyword_sep'] ); ?>><?php esc_html_e( 'Comma (,)', 'wp-content-filter' ); ?></option>
260 <option value="pound" <?php selected( 'pound', $options['drp_keyword_sep'] ); ?>><?php esc_html_e( 'Hash (#)', 'wp-content-filter' ); ?></option>
261 </select>
262 </td>
263 </tr>
264 <tr>
265 <th scope="row"><label for="content-censor-exclusions"><?php esc_html_e( 'Excluded content IDs', 'wp-content-filter' ); ?></label></th>
266 <td>
267 <input class="regular-text code" id="content-censor-exclusions" name="<?php echo esc_attr( $name ); ?>[txt_exclude]" value="<?php echo esc_attr( $options['txt_exclude'] ); ?>">
268 <p class="description"><?php esc_html_e( 'Comma-separated post or page IDs that must never be filtered.', 'wp-content-filter' ); ?></p>
269 </td>
270 </tr>
271 <tr>
272 <th scope="row"><?php esc_html_e( 'Content areas', 'wp-content-filter' ); ?></th>
273 <td>
274 <?php self::checkbox( $name, $options, 'chk_post_content', esc_html__( 'Post and page content and excerpts', 'wp-content-filter' ) ); ?>
275 <?php self::checkbox( $name, $options, 'chk_post_title', esc_html__( 'Titles', 'wp-content-filter' ) ); ?>
276 <?php self::checkbox( $name, $options, 'chk_comments', esc_html__( 'Comments and comment author names', 'wp-content-filter' ) ); ?>
277 <?php self::checkbox( $name, $options, 'chk_tags', esc_html__( 'Post tag links', 'wp-content-filter' ) ); ?>
278 <?php self::checkbox( $name, $options, 'chk_tag_cloud', esc_html__( 'Post tag clouds', 'wp-content-filter' ) ); ?>
279 </td>
280 </tr>
281 <tr>
282 <th scope="row"><?php esc_html_e( 'Matching', 'wp-content-filter' ); ?></th>
283 <td>
284 <fieldset>
285 <legend class="screen-reader-text"><?php esc_html_e( 'Matching', 'wp-content-filter' ); ?></legend>
286 <label><input type="radio" name="<?php echo esc_attr( $name ); ?>[rdo_strict_filtering]" value="strict_on" <?php checked( 'strict_on', $options['rdo_strict_filtering'] ); ?>> <?php esc_html_e( 'Whole words only', 'wp-content-filter' ); ?></label><br>
287 <label><input type="radio" name="<?php echo esc_attr( $name ); ?>[rdo_strict_filtering]" value="strict_off" <?php checked( 'strict_off', $options['rdo_strict_filtering'] ); ?>> <?php esc_html_e( 'Also filter words embedded in longer text', 'wp-content-filter' ); ?></label><br>
288 <label><input type="radio" name="<?php echo esc_attr( $name ); ?>[rdo_case]" value="insen" <?php checked( 'insen', $options['rdo_case'] ); ?>> <?php esc_html_e( 'Case-insensitive', 'wp-content-filter' ); ?></label><br>
289 <label><input type="radio" name="<?php echo esc_attr( $name ); ?>[rdo_case]" value="sen" <?php checked( 'sen', $options['rdo_case'] ); ?>> <?php esc_html_e( 'Case-sensitive', 'wp-content-filter' ); ?></label>
290 </fieldset>
291 </td>
292 </tr>
293 <tr>
294 <th scope="row"><label for="content-censor-rendering"><?php esc_html_e( 'Word rendering', 'wp-content-filter' ); ?></label></th>
295 <td>
296 <select id="content-censor-rendering" name="<?php echo esc_attr( $name ); ?>[rdo_word]">
297 <option value="all" <?php selected( 'all', $options['rdo_word'] ); ?>><?php esc_html_e( 'Replace every character', 'wp-content-filter' ); ?></option>
298 <option value="first" <?php selected( 'first', $options['rdo_word'] ); ?>><?php esc_html_e( 'Keep the first character', 'wp-content-filter' ); ?></option>
299 <option value="firstlast" <?php selected( 'firstlast', $options['rdo_word'] ); ?>><?php esc_html_e( 'Keep the first and last characters', 'wp-content-filter' ); ?></option>
300 </select>
301 <select aria-label="<?php esc_attr_e( 'Replacement character', 'wp-content-filter' ); ?>" name="<?php echo esc_attr( $name ); ?>[drp_filter_char]">
302 <?php foreach ( self::wildcards() as $value => $label ) : ?>
303 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $value, $options['drp_filter_char'] ); ?>><?php echo esc_html( $label ); ?></option>
304 <?php endforeach; ?>
305 </select>
306 </td>
307 </tr>
308 <tr>
309 <th scope="row"><?php esc_html_e( 'HTML handling', 'wp-content-filter' ); ?></th>
310 <td>
311 <?php self::checkbox( $name, $options, 'chk_ignore_html_attr', esc_html__( 'Leave words inside HTML tags and attributes unchanged', 'wp-content-filter' ) ); ?>
312 </td>
313 </tr>
314 <?php do_action( 'content_censor_settings_fields', $name, $options ); ?>
315 </table>
316 <?php submit_button(); ?>
317 </form>
318 <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="content-censor-reset-form">
319 <input type="hidden" name="action" value="content_censor_reset">
320 <?php wp_nonce_field( 'content_censor_reset' ); ?>
321 <?php submit_button( esc_html__( 'Reset settings', 'wp-content-filter' ), 'secondary', 'submit', false ); ?>
322 </form>
323 <?php
324 }
325
326 /**
327 * Render license and support links.
328 *
329 * @return void
330 */
331 private static function render_support() {
332 $account_url = content_censor_fs()->get_account_url();
333 ?>
334 <div class="content-censor-card">
335 <h2><?php esc_html_e( 'License and updates', 'wp-content-filter' ); ?></h2>
336 <p><?php esc_html_e( 'Manage your license, billing details, downloads, and automatic updates through your Content Censor account.', 'wp-content-filter' ); ?></p>
337 <p><a class="button button-primary" href="<?php echo esc_url( $account_url ); ?>"><?php esc_html_e( 'Open account', 'wp-content-filter' ); ?></a></p>
338 </div>
339 <div class="content-censor-card">
340 <h2><?php esc_html_e( 'Documentation and support', 'wp-content-filter' ); ?></h2>
341 <p><a href="https://wpgoplugins.com/document/content-censor/"><?php esc_html_e( 'Read the setup guide', 'wp-content-filter' ); ?></a></p>
342 <p><a href="https://wpgoplugins.com/premium-plugin-support/"><?php esc_html_e( 'Contact WPGO Plugins support', 'wp-content-filter' ); ?></a></p>
343 </div>
344 <?php
345 }
346
347 /**
348 * Render one checkbox control.
349 *
350 * @param string $name Option record name.
351 * @param array<string, string> $options Options.
352 * @param string $key Setting key.
353 * @param string $label Visible label.
354 * @return void
355 */
356 private static function checkbox( $name, array $options, $key, $label ) {
357 ?>
358 <label class="content-censor-checkbox"><input type="checkbox" name="<?php echo esc_attr( $name ); ?>[<?php echo esc_attr( $key ); ?>]" value="1" <?php checked( '1', $options[ $key ] ?? '0' ); ?>> <?php echo esc_html( $label ); ?></label>
359 <?php
360 }
361
362 /**
363 * Build a settings-tab URL.
364 *
365 * @param string $tab Tab slug.
366 * @return string
367 */
368 private static function tab_url( $tab ) {
369 return add_query_arg(
370 array(
371 'page' => self::PAGE_SLUG,
372 'tab' => $tab,
373 ),
374 admin_url( 'options-general.php' )
375 );
376 }
377
378 /**
379 * Sanitize the keyword source while enforcing a practical storage bound.
380 *
381 * @param string $value Raw keywords.
382 * @return string
383 */
384 private static function sanitize_keywords( $value ) {
385 $value = sanitize_textarea_field( $value );
386 return strlen( $value ) > 50000 ? substr( $value, 0, 50000 ) : $value;
387 }
388
389 /**
390 * Return a whitelisted submitted value.
391 *
392 * @param array<string, mixed> $input Input.
393 * @param string $key Key.
394 * @param array<int, string> $allowed Allowed values.
395 * @param string $fallback Fallback.
396 * @return string
397 */
398 private static function allowed( array $input, $key, array $allowed, $fallback ) {
399 $value = isset( $input[ $key ] ) ? (string) $input[ $key ] : '';
400 return in_array( $value, $allowed, true ) ? $value : $fallback;
401 }
402
403 /**
404 * Labels for replacement characters.
405 *
406 * @return array<string, string>
407 */
408 private static function wildcards() {
409 return array(
410 'star' => esc_html__( 'Asterisk (*)', 'wp-content-filter' ),
411 'dollar' => esc_html__( 'Dollar ($)', 'wp-content-filter' ),
412 'question' => esc_html__( 'Question mark (?)', 'wp-content-filter' ),
413 'exclamation' => esc_html__( 'Exclamation mark (!)', 'wp-content-filter' ),
414 'hyphen' => esc_html__( 'Hyphen (-)', 'wp-content-filter' ),
415 'hash' => esc_html__( 'Hash (#)', 'wp-content-filter' ),
416 'tilde' => esc_html__( 'Tilde (~)', 'wp-content-filter' ),
417 'blank' => esc_html__( 'Remove completely', 'wp-content-filter' ),
418 );
419 }
420 }
421