CustomFilter.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. <?php
  2. namespace App\Models;
  3. use App\Profile;
  4. use Illuminate\Database\Eloquent\Model;
  5. use Illuminate\Support\Facades\Cache;
  6. class CustomFilter extends Model
  7. {
  8. public $shouldInvalidateCache = false;
  9. protected $fillable = [
  10. 'title', 'phrase', 'context', 'expires_at', 'action', 'profile_id',
  11. ];
  12. protected $casts = [
  13. 'id' => 'string',
  14. 'context' => 'array',
  15. 'expires_at' => 'datetime',
  16. 'action' => 'integer',
  17. ];
  18. protected $guarded = ['shouldInvalidateCache'];
  19. const VALID_CONTEXTS = [
  20. 'home',
  21. 'notifications',
  22. 'public',
  23. 'thread',
  24. 'account',
  25. ];
  26. const MAX_STATUSES_PER_FILTER = 10;
  27. const EXPIRATION_DURATIONS = [
  28. 1800, // 30 minutes
  29. 3600, // 1 hour
  30. 21600, // 6 hours
  31. 43200, // 12 hours
  32. 86400, // 1 day
  33. 604800, // 1 week
  34. ];
  35. const ACTION_WARN = 0;
  36. const ACTION_HIDE = 1;
  37. const ACTION_BLUR = 2;
  38. protected static ?int $maxContentScanLimit = null;
  39. protected static ?int $maxFiltersPerUser = null;
  40. protected static ?int $maxKeywordsPerFilter = null;
  41. protected static ?int $maxKeywordsLength = null;
  42. protected static ?int $maxPatternLength = null;
  43. protected static ?int $maxCreatePerHour = null;
  44. protected static ?int $maxUpdatesPerHour = null;
  45. public function account()
  46. {
  47. return $this->belongsTo(Profile::class, 'profile_id');
  48. }
  49. public function keywords()
  50. {
  51. return $this->hasMany(CustomFilterKeyword::class);
  52. }
  53. public function statuses()
  54. {
  55. return $this->hasMany(CustomFilterStatus::class);
  56. }
  57. public function toFilterArray()
  58. {
  59. return [
  60. 'id' => $this->id,
  61. 'title' => $this->title,
  62. 'context' => $this->context,
  63. 'expires_at' => $this->expires_at,
  64. 'filter_action' => $this->filterAction,
  65. ];
  66. }
  67. public function getFilterActionAttribute()
  68. {
  69. switch ($this->action) {
  70. case 0:
  71. return 'warn';
  72. break;
  73. case 1:
  74. return 'hide';
  75. break;
  76. case 2:
  77. return 'blur';
  78. break;
  79. }
  80. }
  81. public function getTitleAttribute()
  82. {
  83. return $this->phrase;
  84. }
  85. public function setTitleAttribute($value)
  86. {
  87. $this->attributes['phrase'] = $value;
  88. }
  89. public function setFilterActionAttribute($value)
  90. {
  91. $this->attributes['action'] = $value;
  92. }
  93. public function setIrreversibleAttribute($value)
  94. {
  95. $this->attributes['action'] = $value ? self::ACTION_HIDE : self::ACTION_WARN;
  96. }
  97. public function getIrreversibleAttribute()
  98. {
  99. return $this->action === self::ACTION_HIDE;
  100. }
  101. public function getExpiresInAttribute()
  102. {
  103. if ($this->expires_at === null) {
  104. return null;
  105. }
  106. $now = now();
  107. foreach (self::EXPIRATION_DURATIONS as $duration) {
  108. if ($now->addSeconds($duration)->gte($this->expires_at)) {
  109. return $duration;
  110. }
  111. }
  112. return null;
  113. }
  114. public function scopeUnexpired($query)
  115. {
  116. return $query->where(function ($q) {
  117. $q->whereNull('expires_at')
  118. ->orWhere('expires_at', '>', now());
  119. });
  120. }
  121. public function isExpired()
  122. {
  123. return $this->expires_at !== null && $this->expires_at->isPast();
  124. }
  125. protected static function boot()
  126. {
  127. parent::boot();
  128. static::saving(function ($model) {
  129. $model->prepareContextForStorage();
  130. $model->shouldInvalidateCache = true;
  131. });
  132. static::updating(function ($model) {
  133. $model->prepareContextForStorage();
  134. $model->shouldInvalidateCache = true;
  135. });
  136. static::deleting(function ($model) {
  137. $model->shouldInvalidateCache = true;
  138. });
  139. static::saved(function ($model) {
  140. $model->invalidateCache();
  141. });
  142. static::deleted(function ($model) {
  143. $model->invalidateCache();
  144. });
  145. }
  146. protected function prepareContextForStorage()
  147. {
  148. if (is_array($this->context)) {
  149. $this->context = array_values(array_filter(array_map('trim', $this->context)));
  150. }
  151. }
  152. protected function invalidateCache()
  153. {
  154. if (! isset($this->shouldInvalidateCache) || ! $this->shouldInvalidateCache) {
  155. return;
  156. }
  157. $this->shouldInvalidateCache = false;
  158. Cache::forget("filters:v3:{$this->profile_id}");
  159. }
  160. public static function getMaxContentScanLimit(): int
  161. {
  162. if (self::$maxContentScanLimit === null) {
  163. self::$maxContentScanLimit = config('instance.custom_filters.max_content_scan_limit', 2500);
  164. }
  165. return self::$maxContentScanLimit;
  166. }
  167. public static function getMaxFiltersPerUser(): int
  168. {
  169. if (self::$maxFiltersPerUser === null) {
  170. self::$maxFiltersPerUser = config('instance.custom_filters.max_filters_per_user', 20);
  171. }
  172. return self::$maxFiltersPerUser;
  173. }
  174. public static function getMaxKeywordsPerFilter(): int
  175. {
  176. if (self::$maxKeywordsPerFilter === null) {
  177. self::$maxKeywordsPerFilter = config('instance.custom_filters.max_keywords_per_filter', 10);
  178. }
  179. return self::$maxKeywordsPerFilter;
  180. }
  181. public static function getMaxKeywordLength(): int
  182. {
  183. if (self::$maxKeywordsLength === null) {
  184. self::$maxKeywordsLength = config('instance.custom_filters.max_keyword_length', 40);
  185. }
  186. return self::$maxKeywordsLength;
  187. }
  188. public static function getMaxPatternLength(): int
  189. {
  190. if (self::$maxPatternLength === null) {
  191. self::$maxPatternLength = config('instance.custom_filters.max_pattern_length', 10000);
  192. }
  193. return self::$maxPatternLength;
  194. }
  195. public static function getMaxCreatePerHour(): int
  196. {
  197. if (self::$maxCreatePerHour === null) {
  198. self::$maxCreatePerHour = config('instance.custom_filters.max_create_per_hour', 20);
  199. }
  200. return self::$maxCreatePerHour;
  201. }
  202. public static function getMaxUpdatesPerHour(): int
  203. {
  204. if (self::$maxUpdatesPerHour === null) {
  205. self::$maxUpdatesPerHour = config('instance.custom_filters.max_updates_per_hour', 40);
  206. }
  207. return self::$maxUpdatesPerHour;
  208. }
  209. /**
  210. * Get cached filters for an account with simplified, secure approach
  211. *
  212. * @param int $profileId The profile ID
  213. * @return Collection The collection of filters
  214. */
  215. public static function getCachedFiltersForAccount($profileId)
  216. {
  217. $activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
  218. $filtersHash = [];
  219. $keywordFilters = CustomFilterKeyword::with(['customFilter' => function ($query) use ($profileId) {
  220. $query->unexpired()->where('profile_id', $profileId);
  221. }])->get();
  222. $keywordFilters->groupBy('custom_filter_id')->each(function ($keywords, $filterId) use (&$filtersHash) {
  223. $filter = $keywords->first()->customFilter;
  224. if (! $filter) {
  225. return;
  226. }
  227. $maxPatternsPerFilter = self::getMaxFiltersPerUser();
  228. $keywordsToProcess = $keywords->take($maxPatternsPerFilter);
  229. $regexPatterns = $keywordsToProcess->map(function ($keyword) {
  230. $pattern = preg_quote($keyword->keyword, '/');
  231. if ($keyword->whole_word) {
  232. $pattern = '\b'.$pattern.'\b';
  233. }
  234. return $pattern;
  235. })->toArray();
  236. if (empty($regexPatterns)) {
  237. return;
  238. }
  239. $combinedPattern = implode('|', $regexPatterns);
  240. $maxPatternLength = self::getMaxPatternLength();
  241. if (strlen($combinedPattern) > $maxPatternLength) {
  242. $combinedPattern = substr($combinedPattern, 0, $maxPatternLength);
  243. }
  244. $filtersHash[$filterId] = [
  245. 'keywords' => '/'.$combinedPattern.'/i',
  246. 'filter' => $filter,
  247. ];
  248. });
  249. // $statusFilters = CustomFilterStatus::with(['customFilter' => function ($query) use ($profileId) {
  250. // $query->unexpired()->where('profile_id', $profileId);
  251. // }])->get();
  252. // $statusFilters->groupBy('custom_filter_id')->each(function ($statuses, $filterId) use (&$filtersHash) {
  253. // $filter = $statuses->first()->customFilter;
  254. // if (! $filter) {
  255. // return;
  256. // }
  257. // if (! isset($filtersHash[$filterId])) {
  258. // $filtersHash[$filterId] = ['filter' => $filter];
  259. // }
  260. // $maxStatusIds = self::MAX_STATUSES_PER_FILTER;
  261. // $filtersHash[$filterId]['status_ids'] = $statuses->take($maxStatusIds)->pluck('status_id')->toArray();
  262. // });
  263. return array_map(function ($item) {
  264. $filter = $item['filter'];
  265. unset($item['filter']);
  266. return [$filter, $item];
  267. }, $filtersHash);
  268. });
  269. return collect($activeFilters)->reject(function ($item) {
  270. [$filter, $rules] = $item;
  271. return $filter->isExpired();
  272. })->toArray();
  273. }
  274. /**
  275. * Apply cached filters to a status with reasonable safety measures
  276. *
  277. * @param array $cachedFilters The cached filters
  278. * @param mixed $status The status to check
  279. * @return array The filter matches
  280. */
  281. public static function applyCachedFilters($cachedFilters, $status)
  282. {
  283. $results = [];
  284. foreach ($cachedFilters as [$filter, $rules]) {
  285. $keywordMatches = [];
  286. $statusMatches = null;
  287. if (isset($rules['keywords'])) {
  288. $text = strip_tags($status['content']);
  289. $maxContentLength = self::getMaxContentScanLimit();
  290. if (mb_strlen($text) > $maxContentLength) {
  291. $text = mb_substr($text, 0, $maxContentLength);
  292. }
  293. try {
  294. preg_match_all($rules['keywords'], $text, $matches, PREG_PATTERN_ORDER, 0);
  295. if (! empty($matches[0])) {
  296. $maxReportedMatches = (int) config('instance.custom_filters.max_reported_matches', 10);
  297. $keywordMatches = array_slice($matches[0], 0, $maxReportedMatches);
  298. }
  299. } catch (\Throwable $e) {
  300. \Log::error('Filter regex error: '.$e->getMessage(), [
  301. 'filter_id' => $filter->id,
  302. ]);
  303. }
  304. }
  305. // if (isset($rules['status_ids'])) {
  306. // $statusId = $status->id;
  307. // $reblogId = $status->reblog_of_id ?? null;
  308. // $matchingIds = array_intersect($rules['status_ids'], array_filter([$statusId, $reblogId]));
  309. // if (! empty($matchingIds)) {
  310. // $statusMatches = $matchingIds;
  311. // }
  312. // }
  313. if (! empty($keywordMatches) || ! empty($statusMatches)) {
  314. $results[] = [
  315. 'filter' => $filter->toFilterArray(),
  316. 'keyword_matches' => $keywordMatches ?: null,
  317. 'status_matches' => ! empty($statusMatches) ? $statusMatches : null,
  318. ];
  319. }
  320. }
  321. return $results;
  322. }
  323. }