mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
413 lines
12 KiB
PHP
413 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Profile;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
class CustomFilter extends Model
|
|
{
|
|
public $shouldInvalidateCache = false;
|
|
|
|
protected $fillable = [
|
|
'title', 'phrase', 'context', 'expires_at', 'action', 'profile_id',
|
|
];
|
|
|
|
protected $casts = [
|
|
'id' => 'string',
|
|
'context' => 'array',
|
|
'expires_at' => 'datetime',
|
|
'action' => 'integer',
|
|
];
|
|
|
|
protected $guarded = ['shouldInvalidateCache'];
|
|
|
|
const VALID_CONTEXTS = [
|
|
'home',
|
|
'notifications',
|
|
'public',
|
|
'thread',
|
|
'account',
|
|
];
|
|
|
|
const MAX_STATUSES_PER_FILTER = 10;
|
|
|
|
const EXPIRATION_DURATIONS = [
|
|
1800, // 30 minutes
|
|
3600, // 1 hour
|
|
21600, // 6 hours
|
|
43200, // 12 hours
|
|
86400, // 1 day
|
|
604800, // 1 week
|
|
];
|
|
|
|
const ACTION_WARN = 0;
|
|
|
|
const ACTION_HIDE = 1;
|
|
|
|
const ACTION_BLUR = 2;
|
|
|
|
protected static ?int $maxContentScanLimit = null;
|
|
|
|
protected static ?int $maxFiltersPerUser = null;
|
|
|
|
protected static ?int $maxKeywordsPerFilter = null;
|
|
|
|
protected static ?int $maxKeywordsLength = null;
|
|
|
|
protected static ?int $maxPatternLength = null;
|
|
|
|
protected static ?int $maxCreatePerHour = null;
|
|
|
|
protected static ?int $maxUpdatesPerHour = null;
|
|
|
|
public function account()
|
|
{
|
|
return $this->belongsTo(Profile::class, 'profile_id');
|
|
}
|
|
|
|
public function keywords()
|
|
{
|
|
return $this->hasMany(CustomFilterKeyword::class);
|
|
}
|
|
|
|
public function statuses()
|
|
{
|
|
return $this->hasMany(CustomFilterStatus::class);
|
|
}
|
|
|
|
public function toFilterArray()
|
|
{
|
|
return [
|
|
'id' => $this->id,
|
|
'title' => $this->title,
|
|
'context' => $this->context,
|
|
'expires_at' => $this->expires_at,
|
|
'filter_action' => $this->filterAction,
|
|
];
|
|
}
|
|
|
|
public function getFilterActionAttribute()
|
|
{
|
|
switch ($this->action) {
|
|
case 0:
|
|
return 'warn';
|
|
break;
|
|
|
|
case 1:
|
|
return 'hide';
|
|
break;
|
|
|
|
case 2:
|
|
return 'blur';
|
|
break;
|
|
}
|
|
}
|
|
|
|
public function getTitleAttribute()
|
|
{
|
|
return $this->phrase;
|
|
}
|
|
|
|
public function setTitleAttribute($value)
|
|
{
|
|
$this->attributes['phrase'] = $value;
|
|
}
|
|
|
|
public function setFilterActionAttribute($value)
|
|
{
|
|
$this->attributes['action'] = $value;
|
|
}
|
|
|
|
public function setIrreversibleAttribute($value)
|
|
{
|
|
$this->attributes['action'] = $value ? self::ACTION_HIDE : self::ACTION_WARN;
|
|
}
|
|
|
|
public function getIrreversibleAttribute()
|
|
{
|
|
return $this->action === self::ACTION_HIDE;
|
|
}
|
|
|
|
public function getExpiresInAttribute()
|
|
{
|
|
if ($this->expires_at === null) {
|
|
return null;
|
|
}
|
|
|
|
$now = now();
|
|
foreach (self::EXPIRATION_DURATIONS as $duration) {
|
|
if ($now->addSeconds($duration)->gte($this->expires_at)) {
|
|
return $duration;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function scopeUnexpired($query)
|
|
{
|
|
return $query->where(function ($q) {
|
|
$q->whereNull('expires_at')
|
|
->orWhere('expires_at', '>', now());
|
|
});
|
|
}
|
|
|
|
public function isExpired()
|
|
{
|
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
|
}
|
|
|
|
protected static function boot()
|
|
{
|
|
parent::boot();
|
|
|
|
static::saving(function ($model) {
|
|
$model->prepareContextForStorage();
|
|
$model->shouldInvalidateCache = true;
|
|
});
|
|
|
|
static::updating(function ($model) {
|
|
$model->prepareContextForStorage();
|
|
$model->shouldInvalidateCache = true;
|
|
});
|
|
|
|
static::deleting(function ($model) {
|
|
$model->shouldInvalidateCache = true;
|
|
});
|
|
|
|
static::saved(function ($model) {
|
|
$model->invalidateCache();
|
|
});
|
|
|
|
static::deleted(function ($model) {
|
|
$model->invalidateCache();
|
|
});
|
|
}
|
|
|
|
protected function prepareContextForStorage()
|
|
{
|
|
if (is_array($this->context)) {
|
|
$this->context = array_values(array_filter(array_map('trim', $this->context)));
|
|
}
|
|
}
|
|
|
|
protected function invalidateCache()
|
|
{
|
|
if (! isset($this->shouldInvalidateCache) || ! $this->shouldInvalidateCache) {
|
|
return;
|
|
}
|
|
|
|
$this->shouldInvalidateCache = false;
|
|
|
|
Cache::forget("filters:v3:{$this->profile_id}");
|
|
}
|
|
|
|
public static function getMaxContentScanLimit(): int
|
|
{
|
|
if (self::$maxContentScanLimit === null) {
|
|
self::$maxContentScanLimit = config('instance.custom_filters.max_content_scan_limit', 2500);
|
|
}
|
|
|
|
return self::$maxContentScanLimit;
|
|
}
|
|
|
|
public static function getMaxFiltersPerUser(): int
|
|
{
|
|
if (self::$maxFiltersPerUser === null) {
|
|
self::$maxFiltersPerUser = config('instance.custom_filters.max_filters_per_user', 20);
|
|
}
|
|
|
|
return self::$maxFiltersPerUser;
|
|
}
|
|
|
|
public static function getMaxKeywordsPerFilter(): int
|
|
{
|
|
if (self::$maxKeywordsPerFilter === null) {
|
|
self::$maxKeywordsPerFilter = config('instance.custom_filters.max_keywords_per_filter', 10);
|
|
}
|
|
|
|
return self::$maxKeywordsPerFilter;
|
|
}
|
|
|
|
public static function getMaxKeywordLength(): int
|
|
{
|
|
if (self::$maxKeywordsLength === null) {
|
|
self::$maxKeywordsLength = config('instance.custom_filters.max_keyword_length', 40);
|
|
}
|
|
|
|
return self::$maxKeywordsLength;
|
|
}
|
|
|
|
public static function getMaxPatternLength(): int
|
|
{
|
|
if (self::$maxPatternLength === null) {
|
|
self::$maxPatternLength = config('instance.custom_filters.max_pattern_length', 10000);
|
|
}
|
|
|
|
return self::$maxPatternLength;
|
|
}
|
|
|
|
public static function getMaxCreatePerHour(): int
|
|
{
|
|
if (self::$maxCreatePerHour === null) {
|
|
self::$maxCreatePerHour = config('instance.custom_filters.max_create_per_hour', 20);
|
|
}
|
|
|
|
return self::$maxCreatePerHour;
|
|
}
|
|
|
|
public static function getMaxUpdatesPerHour(): int
|
|
{
|
|
if (self::$maxUpdatesPerHour === null) {
|
|
self::$maxUpdatesPerHour = config('instance.custom_filters.max_updates_per_hour', 40);
|
|
}
|
|
|
|
return self::$maxUpdatesPerHour;
|
|
}
|
|
|
|
/**
|
|
* Get cached filters for an account with simplified, secure approach
|
|
*
|
|
* @param int $profileId The profile ID
|
|
* @return Collection The collection of filters
|
|
*/
|
|
public static function getCachedFiltersForAccount($profileId)
|
|
{
|
|
$activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
|
|
$filtersHash = [];
|
|
|
|
$keywordFilters = CustomFilterKeyword::with(['customFilter' => function ($query) use ($profileId) {
|
|
$query->unexpired()->where('profile_id', $profileId);
|
|
}])->get();
|
|
|
|
$keywordFilters->groupBy('custom_filter_id')->each(function ($keywords, $filterId) use (&$filtersHash) {
|
|
$filter = $keywords->first()->customFilter;
|
|
|
|
if (! $filter) {
|
|
return;
|
|
}
|
|
|
|
$maxPatternsPerFilter = self::getMaxFiltersPerUser();
|
|
$keywordsToProcess = $keywords->take($maxPatternsPerFilter);
|
|
|
|
$regexPatterns = $keywordsToProcess->map(function ($keyword) {
|
|
$pattern = preg_quote($keyword->keyword, '/');
|
|
|
|
if ($keyword->whole_word) {
|
|
$pattern = '\b'.$pattern.'\b';
|
|
}
|
|
|
|
return $pattern;
|
|
})->toArray();
|
|
|
|
if (empty($regexPatterns)) {
|
|
return;
|
|
}
|
|
|
|
$combinedPattern = implode('|', $regexPatterns);
|
|
$maxPatternLength = self::getMaxPatternLength();
|
|
if (strlen($combinedPattern) > $maxPatternLength) {
|
|
$combinedPattern = substr($combinedPattern, 0, $maxPatternLength);
|
|
}
|
|
|
|
$filtersHash[$filterId] = [
|
|
'keywords' => '/'.$combinedPattern.'/i',
|
|
'filter' => $filter,
|
|
];
|
|
});
|
|
|
|
// $statusFilters = CustomFilterStatus::with(['customFilter' => function ($query) use ($profileId) {
|
|
// $query->unexpired()->where('profile_id', $profileId);
|
|
// }])->get();
|
|
|
|
// $statusFilters->groupBy('custom_filter_id')->each(function ($statuses, $filterId) use (&$filtersHash) {
|
|
// $filter = $statuses->first()->customFilter;
|
|
|
|
// if (! $filter) {
|
|
// return;
|
|
// }
|
|
|
|
// if (! isset($filtersHash[$filterId])) {
|
|
// $filtersHash[$filterId] = ['filter' => $filter];
|
|
// }
|
|
|
|
// $maxStatusIds = self::MAX_STATUSES_PER_FILTER;
|
|
// $filtersHash[$filterId]['status_ids'] = $statuses->take($maxStatusIds)->pluck('status_id')->toArray();
|
|
// });
|
|
|
|
return array_map(function ($item) {
|
|
$filter = $item['filter'];
|
|
unset($item['filter']);
|
|
|
|
return [$filter, $item];
|
|
}, $filtersHash);
|
|
});
|
|
|
|
return collect($activeFilters)->reject(function ($item) {
|
|
[$filter, $rules] = $item;
|
|
|
|
return $filter->isExpired();
|
|
})->toArray();
|
|
}
|
|
|
|
/**
|
|
* Apply cached filters to a status with reasonable safety measures
|
|
*
|
|
* @param array $cachedFilters The cached filters
|
|
* @param mixed $status The status to check
|
|
* @return array The filter matches
|
|
*/
|
|
public static function applyCachedFilters($cachedFilters, $status)
|
|
{
|
|
$results = [];
|
|
|
|
foreach ($cachedFilters as [$filter, $rules]) {
|
|
$keywordMatches = [];
|
|
$statusMatches = null;
|
|
|
|
if (isset($rules['keywords'])) {
|
|
$text = strip_tags($status['content']);
|
|
|
|
$maxContentLength = self::getMaxContentScanLimit();
|
|
if (mb_strlen($text) > $maxContentLength) {
|
|
$text = mb_substr($text, 0, $maxContentLength);
|
|
}
|
|
|
|
try {
|
|
preg_match_all($rules['keywords'], $text, $matches, PREG_PATTERN_ORDER, 0);
|
|
if (! empty($matches[0])) {
|
|
$maxReportedMatches = (int) config('instance.custom_filters.max_reported_matches', 10);
|
|
$keywordMatches = array_slice($matches[0], 0, $maxReportedMatches);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
\Log::error('Filter regex error: '.$e->getMessage(), [
|
|
'filter_id' => $filter->id,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// if (isset($rules['status_ids'])) {
|
|
// $statusId = $status->id;
|
|
// $reblogId = $status->reblog_of_id ?? null;
|
|
|
|
// $matchingIds = array_intersect($rules['status_ids'], array_filter([$statusId, $reblogId]));
|
|
// if (! empty($matchingIds)) {
|
|
// $statusMatches = $matchingIds;
|
|
// }
|
|
// }
|
|
|
|
if (! empty($keywordMatches) || ! empty($statusMatches)) {
|
|
$results[] = [
|
|
'filter' => $filter->toFilterArray(),
|
|
'keyword_matches' => $keywordMatches ?: null,
|
|
'status_matches' => ! empty($statusMatches) ? $statusMatches : null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
}
|