CensorWords.php 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <?php
  2. namespace Snipe\BanBuilder;
  3. class CensorWords
  4. {
  5. public $badwords;
  6. /*
  7. * When the dictionary is loaded, a ton of regular expression strings are generated
  8. * These regular expressions are used to perform the profanity checks.
  9. * Store them here so when we call censorString we don't need to regenerate them on every call
  10. */
  11. private $censorChecks = null;
  12. /**
  13. * @var array
  14. */
  15. private $whiteList = [];
  16. /**
  17. * @var string
  18. */
  19. private $whiteListPlaceHolder = ' {whiteList[i]} ';
  20. public function __construct()
  21. {
  22. $this->badwords = array();
  23. $this->replacer = '*';
  24. $this->setDictionary('en-us');
  25. }
  26. /**
  27. * Sets the dictionar(y|ies) to use
  28. * This can accept a string to a language file path,
  29. * or an array of strings to multiple paths
  30. *
  31. * @param string|array
  32. * @throws \RuntimeException if a dictionary file is not found
  33. */
  34. public function setDictionary($dictionary)
  35. {
  36. $this->badwords = $this->readBadWords($dictionary);
  37. }
  38. /**
  39. * Add more the dictionar(y|ies) to current bad words list
  40. * This can accept a string to a language file path,
  41. * or an array of strings to multiple paths
  42. *
  43. * @param string|array
  44. * @throws \RuntimeException if a dictionary file is not found
  45. */
  46. public function addDictionary($dictionary)
  47. {
  48. $this->badwords = array_merge($this->badwords, $this->readBadWords($dictionary));
  49. }
  50. /**
  51. * Adds more words to current bad words list from an array of words.
  52. *
  53. * @param array
  54. */
  55. public function addFromArray($words)
  56. {
  57. $badwords = array_merge($this->badwords, $words);
  58. $this->badwords = array_keys(array_count_values($badwords));
  59. }
  60. /**
  61. * Read bad words list from dictionar(y|ies) and return it
  62. *
  63. * @param string|array a language identifier or path for a dictionary (or an array of identifiers/paths)
  64. *
  65. * @throws \RuntimeException if a dictionary file is not found
  66. *
  67. * @return array de-duplicated array of bad words
  68. */
  69. private function readBadWords($dictionary)
  70. {
  71. $badwords = array();
  72. $baseDictPath = __DIR__ . DIRECTORY_SEPARATOR . 'dict' . DIRECTORY_SEPARATOR;
  73. if (is_array($dictionary)) {
  74. foreach ($dictionary as $dictionary_file) {
  75. $badwords = array_merge($badwords, $this->readBadWords($dictionary_file));
  76. }
  77. }
  78. // just a single string, not an array
  79. if (is_string($dictionary)) {
  80. if (file_exists($baseDictPath . $dictionary . '.php')) {
  81. include $baseDictPath . $dictionary . '.php';
  82. } elseif (file_exists($dictionary)) {
  83. include $dictionary;
  84. } else {
  85. throw new \RuntimeException('Dictionary file not found: ' . $dictionary);
  86. }
  87. }
  88. // counting values and then only returning the keys is said
  89. // to be more efficient than array_values(array_unique())
  90. return array_keys(array_count_values($badwords));
  91. }
  92. /**
  93. * List of word to add which will be overridden
  94. *
  95. * @param array $list
  96. */
  97. public function addWhiteList(array $list)
  98. {
  99. foreach ($list as $value) {
  100. if (is_string($value) && !empty($value)) {
  101. $this->whiteList[]['word'] = $value;
  102. }
  103. }
  104. }
  105. /**
  106. * Replace white listed words with placeholders and inversely
  107. *
  108. * @param $string
  109. * @param bool $reverse
  110. *
  111. * @return mixed
  112. */
  113. private function replaceWhiteListed($string, $reverse = false)
  114. {
  115. foreach ($this->whiteList as $key => $list) {
  116. if ($reverse && !empty($this->whiteList[$key]['placeHolder'])) {
  117. $placeHolder = $this->whiteList[$key]['placeHolder'];
  118. $string = str_replace($placeHolder, $list['word'], $string);
  119. } else {
  120. $placeHolder = str_replace('[i]', $key, $this->whiteListPlaceHolder);
  121. $this->whiteList[$key]['placeHolder'] = $placeHolder;
  122. $string = str_replace($list['word'], $placeHolder, $string);
  123. }
  124. }
  125. return $string;
  126. }
  127. /**
  128. * Sets the replacement character to use
  129. *
  130. * @param string $replacer Character to use.
  131. */
  132. public function setReplaceChar($replacer)
  133. {
  134. $this->replacer = $replacer;
  135. }
  136. /**
  137. * Generates a random string.
  138. *
  139. * @param string $chars Chars that can be used.
  140. * @param int $len Length of the output string.
  141. *
  142. *
  143. * @return string
  144. */
  145. public function randCensor($chars, $len)
  146. {
  147. return str_shuffle(
  148. str_repeat($chars, (int)($len / strlen($chars))) .
  149. substr($chars, 0, $len % strlen($chars))
  150. );
  151. }
  152. /**
  153. * Generates the regular expressions that are going to be used to check for profanity
  154. *
  155. * @param boolean $fullWords Option to generate regular expressions used for full words instead. Default is
  156. * false
  157. */
  158. private function generateCensorChecks($fullWords = false)
  159. {
  160. $badwords = $this->badwords;
  161. // generate censor checks as soon as we load the dictionary
  162. // utilize leet equivalents as well
  163. $leet_replace = array();
  164. $leet_replace['a'] = '(a|a\.|a\-|4|@|Á|á|À|Â|à|Â|â|Ä|ä|Ã|ã|Å|å|α|Δ|Λ|λ)';
  165. $leet_replace['b'] = '(b|b\.|b\-|8|\|3|ß|Β|β)';
  166. $leet_replace['c'] = '(c|c\.|c\-|Ç|ç|¢|€|<|\(|{|©)';
  167. $leet_replace['d'] = '(d|d\.|d\-|&part;|\|\)|Þ|þ|Ð|ð)';
  168. $leet_replace['e'] = '(e|e\.|e\-|3|€|È|è|É|é|Ê|ê|∑)';
  169. $leet_replace['f'] = '(f|f\.|f\-|ƒ)';
  170. $leet_replace['g'] = '(g|g\.|g\-|6|9)';
  171. $leet_replace['h'] = '(h|h\.|h\-|Η)';
  172. $leet_replace['i'] = '(i|i\.|i\-|!|\||\]\[|]|1|∫|Ì|Í|Î|Ï|ì|í|î|ï)';
  173. $leet_replace['j'] = '(j|j\.|j\-)';
  174. $leet_replace['k'] = '(k|k\.|k\-|Κ|κ)';
  175. $leet_replace['l'] = '(l|1\.|l\-|!|\||\]\[|]|£|∫|Ì|Í|Î|Ï)';
  176. $leet_replace['m'] = '(m|m\.|m\-)';
  177. $leet_replace['n'] = '(n|n\.|n\-|η|Ν|Π)';
  178. $leet_replace['o'] = '(o|o\.|o\-|0|Ο|ο|Φ|¤|°|ø)';
  179. $leet_replace['p'] = '(p|p\.|p\-|ρ|Ρ|¶|þ)';
  180. $leet_replace['q'] = '(q|q\.|q\-)';
  181. $leet_replace['r'] = '(r|r\.|r\-|®)';
  182. $leet_replace['s'] = '(s|s\.|s\-|5|\$|§)';
  183. $leet_replace['t'] = '(t|t\.|t\-|Τ|τ|7)';
  184. $leet_replace['u'] = '(u|u\.|u\-|υ|µ)';
  185. $leet_replace['v'] = '(v|v\.|v\-|υ|ν)';
  186. $leet_replace['w'] = '(w|w\.|w\-|ω|ψ|Ψ)';
  187. $leet_replace['x'] = '(x|x\.|x\-|Χ|χ)';
  188. $leet_replace['y'] = '(y|y\.|y\-|¥|γ|ÿ|ý|Ÿ|Ý)';
  189. $leet_replace['z'] = '(z|z\.|z\-|Ζ)';
  190. $censorChecks = array();
  191. for ($x = 0, $xMax = count($badwords); $x < $xMax; $x++) {
  192. $censorChecks[$x] = $fullWords
  193. ? '/\b' . str_ireplace(array_keys($leet_replace), array_values($leet_replace), $badwords[$x]) . '\b/iu'
  194. : '/' . str_ireplace(array_keys($leet_replace), array_values($leet_replace), $badwords[$x]) . '/i';
  195. }
  196. $this->censorChecks = $censorChecks;
  197. }
  198. /**
  199. * Apply censorship to $string, replacing $badwords with $censorChar.
  200. *
  201. * @param string $string String to be censored.
  202. * @param bool $fullWords Option to censor by word only.
  203. *
  204. * @return array
  205. */
  206. public function censorString($string, $fullWords = false)
  207. {
  208. // generate our censor checks if they are not defined yet
  209. if (!$this->censorChecks) {
  210. $this->generateCensorChecks($fullWords);
  211. }
  212. $anThis = &$this;
  213. $counter = 0;
  214. $match = array();
  215. $newstring = array();
  216. $newstring['orig'] = html_entity_decode($string);
  217. $original = $this->replaceWhiteListed($newstring['orig']);
  218. // $anThis for <= PHP5.3
  219. $newstring['clean'] = preg_replace_callback(
  220. $this->censorChecks,
  221. function ($matches) use (&$anThis, &$counter, &$match) {
  222. $match[$counter++] = $matches[0];
  223. // is $anThis->replacer a single char?
  224. return (strlen($anThis->replacer) === 1)
  225. ? str_repeat($anThis->replacer, strlen($matches[0]))
  226. : $anThis->randCensor($anThis->replacer, strlen($matches[0]));
  227. },
  228. $original
  229. );
  230. $newstring['clean'] = $this->replaceWhiteListed($newstring['clean'], true);
  231. $newstring['matched'] = $match;
  232. return $newstring;
  233. }
  234. }