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-filter-engine.php

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

220 lines 6.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Deterministic content filtering engine.
4 *
5 * @package WPGO_Content_Censor
6 */
7
8 namespace WPGO\Content_Censor;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Filters scalar strings and string arrays without depending on global state.
16 */
17 final class Filter_Engine {
18
19 /** Maximum number of configured keywords evaluated in one pass. */
20 const MAX_KEYWORDS = 500;
21
22 /** Maximum UTF-8 length of one keyword. */
23 const MAX_KEYWORD_LENGTH = 200;
24
25 /**
26 * Filter a WordPress value.
27 *
28 * @param mixed $value String or array of strings.
29 * @param array<string, mixed> $options Plugin options.
30 * @param int $post_id Current post ID, when available.
31 * @return mixed
32 */
33 public static function filter( $value, array $options, $post_id = 0 ) {
34 if ( self::is_excluded( (int) $post_id, $options ) ) {
35 return $value;
36 }
37
38 if ( is_array( $value ) ) {
39 $filtered = array();
40 foreach ( $value as $key => $item ) {
41 $filtered[ $key ] = self::filter( $item, $options, $post_id );
42 }
43 return $filtered;
44 }
45
46 if ( ! is_string( $value ) || '' === $value ) {
47 return $value;
48 }
49
50 $keywords = self::keywords( $options );
51 if ( array() === $keywords ) {
52 return $value;
53 }
54
55 if ( '1' === (string) ( $options['chk_ignore_html_attr'] ?? '0' ) && function_exists( 'wp_html_split' ) ) {
56 $parts = wp_html_split( $value );
57 foreach ( $parts as $index => $part ) {
58 if ( '' !== $part && '<' !== $part[0] ) {
59 $parts[ $index ] = self::filter_text( $part, $keywords, $options );
60 }
61 }
62 return implode( '', $parts );
63 }
64
65 return self::filter_text( $value, $keywords, $options );
66 }
67
68 /**
69 * Parse and normalize configured keywords.
70 *
71 * @param array<string, mixed> $options Plugin options.
72 * @return array<int, string>
73 */
74 public static function keywords( array $options ) {
75 $delimiter_map = array(
76 'pipe' => '|',
77 'comma' => ',',
78 'pound' => '#',
79 );
80 $delimiter = $delimiter_map[ $options['drp_keyword_sep'] ?? 'pipe' ] ?? '|';
81 $raw = (string) ( $options['txtar_keywords'] ?? '' );
82 $keywords = array();
83
84 foreach ( explode( $delimiter, $raw ) as $candidate ) {
85 $candidate = trim( $candidate );
86 $length = self::length( $candidate );
87 if ( $length < 3 || $length > self::MAX_KEYWORD_LENGTH ) {
88 continue;
89 }
90 $keywords[ $candidate ] = $candidate;
91 if ( count( $keywords ) >= self::MAX_KEYWORDS ) {
92 break;
93 }
94 }
95
96 $keywords = array_values( $keywords );
97 usort(
98 $keywords,
99 static function ( $left, $right ) {
100 return Filter_Engine::length( $right ) <=> Filter_Engine::length( $left );
101 }
102 );
103 return $keywords;
104 }
105
106 /**
107 * Render one matched word using the selected mask.
108 *
109 * @param string $word Matched word.
110 * @param string $mode all, first, or firstlast.
111 * @param string $wildcard Replacement character.
112 * @return string
113 */
114 public static function censor_word( $word, $mode, $wildcard ) {
115 $characters = preg_split( '//u', $word, -1, PREG_SPLIT_NO_EMPTY );
116 if ( false === $characters ) {
117 $characters = str_split( $word );
118 }
119 $count = count( $characters );
120 if ( 0 === $count ) {
121 return $word;
122 }
123
124 if ( 'first' === $mode ) {
125 return $characters[0] . str_repeat( $wildcard, max( 0, $count - 1 ) );
126 }
127 if ( 'firstlast' === $mode && $count > 1 ) {
128 return $characters[0] . str_repeat( $wildcard, max( 0, $count - 2 ) ) . $characters[ $count - 1 ];
129 }
130 return str_repeat( $wildcard, $count );
131 }
132
133 /**
134 * Filter one plain-text fragment.
135 *
136 * @param string $text Text fragment.
137 * @param array<int, string> $keywords Normalized keywords.
138 * @param array<string, mixed> $options Plugin options.
139 * @return string
140 */
141 private static function filter_text( $text, array $keywords, array $options ) {
142 $alternatives = array_map(
143 static function ( $keyword ) {
144 return preg_quote( $keyword, '/' );
145 },
146 $keywords
147 );
148 $body = '(?:' . implode( '|', $alternatives ) . ')';
149 $strict = 'strict_off' !== (string) ( $options['rdo_strict_filtering'] ?? 'strict_on' );
150 if ( $strict ) {
151 $body = '(?<![\p{L}\p{N}_])' . $body . '(?![\p{L}\p{N}_])';
152 }
153
154 $case_insensitive = 'sen' !== (string) ( $options['rdo_case'] ?? 'insen' );
155 $utf8 = 1 === preg_match( '//u', $text );
156 $modifiers = ( $case_insensitive ? 'i' : '' ) . ( $utf8 ? 'u' : '' );
157 $pattern = '/' . $body . '/' . $modifiers;
158 $mode = (string) ( $options['rdo_word'] ?? 'all' );
159 $wildcard = self::wildcard( (string) ( $options['drp_filter_char'] ?? 'star' ) );
160
161 $result = preg_replace_callback(
162 $pattern,
163 static function ( array $matched ) use ( $mode, $wildcard ) {
164 return Filter_Engine::censor_word( $matched[0], $mode, $wildcard );
165 },
166 $text
167 );
168
169 return null === $result ? $text : $result;
170 }
171
172 /**
173 * Check the configured exclusion list.
174 *
175 * @param int $post_id Current post ID.
176 * @param array<string, mixed> $options Plugin options.
177 * @return bool
178 */
179 private static function is_excluded( $post_id, array $options ) {
180 if ( $post_id <= 0 ) {
181 return false;
182 }
183 $ids = preg_split( '/\s*,\s*/', (string) ( $options['txt_exclude'] ?? '' ), -1, PREG_SPLIT_NO_EMPTY );
184 if ( false === $ids ) {
185 return false;
186 }
187 return in_array( $post_id, array_map( 'intval', $ids ), true );
188 }
189
190 /**
191 * Convert the setting key into a replacement character.
192 *
193 * @param string $name Setting key.
194 * @return string
195 */
196 private static function wildcard( $name ) {
197 $wildcards = array(
198 'star' => '*',
199 'dollar' => '$',
200 'question' => '?',
201 'exclamation' => '!',
202 'hyphen' => '-',
203 'hash' => '#',
204 'tilde' => '~',
205 'blank' => '',
206 );
207 return $wildcards[ $name ] ?? '*';
208 }
209
210 /**
211 * Return the Unicode-aware string length when available.
212 *
213 * @param string $value String value.
214 * @return int
215 */
216 private static function length( $value ) {
217 return function_exists( 'mb_strlen' ) ? mb_strlen( $value, 'UTF-8' ) : strlen( $value );
218 }
219 }
220