|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
|
|
|
|
use Illuminate\Database\Eloquent\Model;
|
fix: add return type declarations to Eloquent relation methods
Larastan 3.x requires explicit return types on relation methods to
verify relation existence when using with(), has(), etc. This adds
the appropriate return type declarations to all relation methods
flagged by the larastan.relationExistence rule.
Models fixed:
- Profile (avatar, statuses)
- User (profile)
- Status (profile, media, hashtags)
- DirectMessage (status, author, recipient)
- Report (reporter, status, reportedUser)
- Like (actor, status)
- Media (status)
- Notification (item)
- HashtagFollow (hashtag)
- OauthClient (user)
- Story (profile)
- StatusHashtag (status, hashtag, profile, media)
- AccountInterstitial (user)
- Hashtag (posts)
- CustomFilter (keywords)
- CustomFilterKeyword (customFilter)
- AdminShadowFilter (profile)
- ImportPost (status)
4 weeks ago
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
fix: add missing use imports to resolve phpstan class.notFound errors
Add missing imports for Log, Cache, DB, FollowerService, StatusService,
LikeService, ReblogService, UserFilterService, AdminProfile, OauthClient,
and fix StatusTimelineTransformer reference (class didn't exist, replaced
with StatusTransformer).
4 weeks ago
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
|
|
|
|
|
class CustomFilter extends Model
|
|
|
|
|
{
|
|
|
|
|
public $shouldInvalidateCache = false;
|
|
|
|
|
|
|
|
|
|
protected $guarded = [];
|
|
|
|
|
|
|
|
|
|
protected function casts(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => 'string',
|
|
|
|
|
'context' => 'array',
|
|
|
|
|
'expires_at' => 'datetime',
|
|
|
|
|
'action' => 'integer',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
fix: add return type declarations to Eloquent relation methods
Larastan 3.x requires explicit return types on relation methods to
verify relation existence when using with(), has(), etc. This adds
the appropriate return type declarations to all relation methods
flagged by the larastan.relationExistence rule.
Models fixed:
- Profile (avatar, statuses)
- User (profile)
- Status (profile, media, hashtags)
- DirectMessage (status, author, recipient)
- Report (reporter, status, reportedUser)
- Like (actor, status)
- Media (status)
- Notification (item)
- HashtagFollow (hashtag)
- OauthClient (user)
- Story (profile)
- StatusHashtag (status, hashtag, profile, media)
- AccountInterstitial (user)
- Hashtag (posts)
- CustomFilter (keywords)
- CustomFilterKeyword (customFilter)
- AdminShadowFilter (profile)
- ImportPost (status)
4 weeks ago
|
|
|
public function keywords(): HasMany
|
|
|
|
|
{
|
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
case 1:
|
|
|
|
|
return 'hide';
|
|
|
|
|
|
|
|
|
|
case 2:
|
|
|
|
|
return 'blur';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|