mirror of https://github.com/pixelfed/pixelfed
Add custom filters
Add custom filters, compatible with Mastodon `/api/v2/filters` Todo: - [ ] fix routes - [ ] finish other context filteringpull/5928/head
parent
ccc7f2fc69
commit
437d742ac4
@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CustomFilter;
|
||||
use App\Models\CustomFilterKeyword;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CustomFilterController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = CustomFilter::where('profile_id', $request->user()->profile_id)
|
||||
->unexpired()
|
||||
->with(['keywords', 'statuses'])
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(CustomFilter $filter)
|
||||
{
|
||||
$this->authorize('view', $filter);
|
||||
|
||||
$filter->load(['keywords', 'statuses']);
|
||||
|
||||
return response()->json([
|
||||
'filter' => $filter,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validatedData = $request->validate([
|
||||
'title' => 'required|string',
|
||||
'context' => 'required|array',
|
||||
'context.*' => 'string|in:'.implode(',', CustomFilter::VALID_CONTEXTS),
|
||||
'filter_action' => 'integer|in:0,1,2',
|
||||
'expires_in' => 'nullable|integer|min:0',
|
||||
'irreversible' => 'boolean',
|
||||
'keywords' => 'array',
|
||||
'keywords.*.keyword' => 'required|string',
|
||||
'keywords.*.whole_word' => 'boolean',
|
||||
'status_ids' => 'array',
|
||||
'status_ids.*' => 'integer|exists:statuses,id',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$expiresAt = null;
|
||||
if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
|
||||
$expiresAt = now()->addSeconds($validatedData['expires_in']);
|
||||
}
|
||||
|
||||
$filter = CustomFilter::create([
|
||||
'phrase' => $validatedData['title'],
|
||||
'context' => $validatedData['context'],
|
||||
'action' => $validatedData['filter_action'] ??
|
||||
(isset($validatedData['irreversible']) && $validatedData['irreversible'] ?
|
||||
CustomFilter::ACTION_HIDE : CustomFilter::ACTION_WARN),
|
||||
'expires_at' => $expiresAt,
|
||||
'profile_id' => $request->user()->profile_id,
|
||||
]);
|
||||
|
||||
if (isset($validatedData['keywords'])) {
|
||||
foreach ($validatedData['keywords'] as $keywordData) {
|
||||
$filter->keywords()->create([
|
||||
'keyword' => $keywordData['keyword'],
|
||||
'whole_word' => $keywordData['whole_word'] ?? true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($validatedData['status_ids'])) {
|
||||
foreach ($validatedData['status_ids'] as $statusId) {
|
||||
$filter->statuses()->create([
|
||||
'status_id' => $statusId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$filter->load(['keywords', 'statuses']);
|
||||
|
||||
return response()->json([
|
||||
'filter' => $filter,
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to create filter',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, CustomFilter $filter)
|
||||
{
|
||||
$this->authorize('update', $filter);
|
||||
|
||||
$validatedData = $request->validate([
|
||||
'title' => 'string',
|
||||
'context' => 'array',
|
||||
'context.*' => 'string|in:'.implode(',', CustomFilter::VALID_CONTEXTS),
|
||||
'filter_action' => 'integer|in:0,1,2',
|
||||
'expires_in' => 'nullable|integer|min:0',
|
||||
'irreversible' => 'boolean',
|
||||
'keywords' => 'array',
|
||||
'keywords.*.id' => 'nullable|exists:custom_filter_keywords,id',
|
||||
'keywords.*.keyword' => 'required|string',
|
||||
'keywords.*.whole_word' => 'boolean',
|
||||
'keywords.*._destroy' => 'boolean',
|
||||
'status_ids' => 'array',
|
||||
'status_ids.*' => 'integer|exists:statuses,id',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
if (isset($validatedData['expires_in'])) {
|
||||
if ($validatedData['expires_in'] > 0) {
|
||||
$filter->expires_at = now()->addSeconds($validatedData['expires_in']);
|
||||
} else {
|
||||
$filter->expires_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($validatedData['title'])) {
|
||||
$filter->phrase = $validatedData['title'];
|
||||
}
|
||||
|
||||
if (isset($validatedData['context'])) {
|
||||
$filter->context = $validatedData['context'];
|
||||
}
|
||||
|
||||
if (isset($validatedData['filter_action'])) {
|
||||
$filter->action = $validatedData['filter_action'];
|
||||
} elseif (isset($validatedData['irreversible'])) {
|
||||
$filter->irreversible = $validatedData['irreversible'];
|
||||
}
|
||||
|
||||
$filter->save();
|
||||
|
||||
if (isset($validatedData['keywords'])) {
|
||||
$existingKeywordIds = $filter->keywords->pluck('id')->toArray();
|
||||
$processedIds = [];
|
||||
|
||||
foreach ($validatedData['keywords'] as $keywordData) {
|
||||
if (isset($keywordData['id']) && isset($keywordData['_destroy']) && $keywordData['_destroy']) {
|
||||
CustomFilterKeyword::destroy($keywordData['id']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($keywordData['id']) && in_array($keywordData['id'], $existingKeywordIds)) {
|
||||
$keyword = CustomFilterKeyword::find($keywordData['id']);
|
||||
$keyword->update([
|
||||
'keyword' => $keywordData['keyword'],
|
||||
'whole_word' => $keywordData['whole_word'] ?? $keyword->whole_word,
|
||||
]);
|
||||
$processedIds[] = $keywordData['id'];
|
||||
} else {
|
||||
$newKeyword = $filter->keywords()->create([
|
||||
'keyword' => $keywordData['keyword'],
|
||||
'whole_word' => $keywordData['whole_word'] ?? true,
|
||||
]);
|
||||
$processedIds[] = $newKeyword->id;
|
||||
}
|
||||
}
|
||||
|
||||
$keywordsToDelete = array_diff($existingKeywordIds, $processedIds);
|
||||
if (! empty($keywordsToDelete)) {
|
||||
CustomFilterKeyword::destroy($keywordsToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($validatedData['status_ids'])) {
|
||||
$filter->statuses()->delete();
|
||||
|
||||
foreach ($validatedData['status_ids'] as $statusId) {
|
||||
$filter->statuses()->create([
|
||||
'status_id' => $statusId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
$filter->load(['keywords', 'statuses']);
|
||||
|
||||
return response()->json([
|
||||
'filter' => $filter,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Failed to update filter',
|
||||
'message' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(CustomFilter $filter)
|
||||
{
|
||||
$this->authorize('delete', $filter);
|
||||
|
||||
$filter->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomFilterKeywordController extends Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomFilterStatusController extends Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
<?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 = [
|
||||
'phrase', 'context', 'expires_at', 'action', 'profile_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'context' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'action' => 'integer',
|
||||
];
|
||||
|
||||
protected $guarded = ['shouldInvalidateCache'];
|
||||
|
||||
const VALID_CONTEXTS = [
|
||||
'home',
|
||||
'notifications',
|
||||
'public',
|
||||
'thread',
|
||||
'account',
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
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 getTitleAttribute()
|
||||
{
|
||||
return $this->phrase;
|
||||
}
|
||||
|
||||
public function setTitleAttribute($value)
|
||||
{
|
||||
$this->attributes['phrase'] = $value;
|
||||
}
|
||||
|
||||
public function getFilterActionAttribute()
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
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::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 getCachedFiltersForAccount($profileId)
|
||||
{
|
||||
$activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
|
||||
$filtersHash = [];
|
||||
|
||||
// Get keyword filters
|
||||
$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;
|
||||
}
|
||||
|
||||
$regexPatterns = $keywords->map(function ($keyword) {
|
||||
$pattern = preg_quote($keyword->keyword, '/');
|
||||
if ($keyword->whole_word) {
|
||||
$pattern = '\b'.$pattern.'\b';
|
||||
}
|
||||
|
||||
return $pattern;
|
||||
})->toArray();
|
||||
|
||||
$filtersHash[$filterId] = [
|
||||
'keywords' => '/'.implode('|', $regexPatterns).'/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];
|
||||
}
|
||||
|
||||
$filtersHash[$filterId]['status_ids'] = $statuses->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();
|
||||
}
|
||||
|
||||
public static function applyCachedFilters($cachedFilters, $status)
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($cachedFilters as [$filter, $rules]) {
|
||||
$keywordMatches = [];
|
||||
$statusMatches = [];
|
||||
|
||||
if (isset($rules['keywords'])) {
|
||||
$text = $status['content'];
|
||||
preg_match_all($rules['keywords'], $text, $matches);
|
||||
if (! empty($matches[0])) {
|
||||
$keywordMatches = $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Check for status matches
|
||||
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,
|
||||
'keyword_matches' => $keywordMatches,
|
||||
'status_matches' => $statusMatches,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CustomFilterKeyword extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'keyword', 'whole_word', 'custom_filter_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'whole_word' => 'boolean',
|
||||
];
|
||||
|
||||
public function customFilter()
|
||||
{
|
||||
return $this->belongsTo(CustomFilter::class);
|
||||
}
|
||||
|
||||
public function toRegex()
|
||||
{
|
||||
$pattern = preg_quote($this->keyword, '/');
|
||||
|
||||
if ($this->whole_word) {
|
||||
$pattern = '\b'.$pattern.'\b';
|
||||
}
|
||||
|
||||
return '/'.$pattern.'/i';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Status;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CustomFilterStatus extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'custom_filter_id', 'status_id',
|
||||
];
|
||||
|
||||
public function customFilter()
|
||||
{
|
||||
return $this->belongsTo(CustomFilter::class);
|
||||
}
|
||||
|
||||
public function status()
|
||||
{
|
||||
return $this->belongsTo(Status::class);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\CustomFilter;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class CustomFilterPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function view(User $user, CustomFilter $filter)
|
||||
{
|
||||
return $user->profile_id === $filter->profile_id;
|
||||
}
|
||||
|
||||
public function update(User $user, CustomFilter $filter)
|
||||
{
|
||||
return $user->profile_id === $filter->profile_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, CustomFilter $filter)
|
||||
{
|
||||
return $user->profile_id === $filter->profile_id;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('custom_filters', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('profile_id')->constrained()->onDelete('cascade');
|
||||
$table->text('phrase')->default('')->nullable(false);
|
||||
$table->integer('action')->default(0)->nullable(false); // 0=warn, 1=hide, 2=blur
|
||||
$table->json('context')->nullable(true);
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('custom_filters');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('custom_filter_keywords', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
|
||||
$table->string('keyword', 255)->nullable(false);
|
||||
$table->boolean('whole_word')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('custom_filter_keywords');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('custom_filter_statuses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('status_id')->constrained()->onDelete('cascade');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('custom_filter_statuses');
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue