mirror of
https://github.com/torrentpier/torrentpier
synced 2025-08-21 22:03:49 -07:00
feat(word-filter): implement comprehensive word filtering system (#2035)
* feat(word-filter): implement comprehensive word filtering system with validation fixes ## Major Features Added ### Complete Word Filter System Implementation - Add comprehensive WordFilter model with Laravel Scout searchable functionality - Create dedicated API and web controllers for full CRUD operations - Implement robust request validation for all operations with conditional rules - Add custom ValidRegexRule for regex pattern validation with error handling - Create migration with proper indexing for performance optimization - Add factory and seeder scaffolding for testing and development - Implement authorization policies with restrictive permissions by default - Add API resource for structured JSON responses with conditional relationships - Create comprehensive test suite for all word filter functionality - Add dedicated API documentation for word filter endpoints ### Word Filter Capabilities - **Filter Types**: replace, block, moderate for different moderation strategies - **Pattern Types**: exact, wildcard, regex for flexible pattern matching - **Severity Levels**: low, medium, high for escalation workflows - **Content Targeting**: posts, private messages, usernames, signatures, profile fields - **Creator Tracking**: Optional user relationship with cascade deletion - **Search Integration**: Full-text search via Laravel Scout with advanced filtering - **Performance**: Database indexes on critical fields for query optimization ### Request Validation Features - Conditional validation based on filter and pattern types - Custom regex validation with proper error handling - Dynamic replacement field requirements for replace-type filters - Comprehensive validation rules for all input parameters - Custom error messages for improved user experience ## Bug Fixes and Improvements ### Word Filter Validation Fixes - **UpdateWordFilterRequest:66**: Fix validation bypass by replacing `\!$this->has('replacement')` with `\!$this->filled('replacement')` to properly validate empty/null replacement values when changing filter type to 'replace' - **WordFilterController:71-73**: Fix inconsistent relationship loading in show() method by making creator relationship conditional based on 'with_creator' parameter, maintaining consistency with other controller methods ### Additional Improvements - **EmojiController:103**: Fix type casting issue in search method by adding explicit (int) cast to limit parameter for Laravel Scout take() method - **EmojiSeeder:26-29**: Refactor category creation using array_map for cleaner, more functional code style - **Documentation**: Add blog post truncation and improve API documentation formatting - **Routes**: Add complete route definitions for word filter API endpoints - **Tests**: Update test configuration to include word filter functionality ## Technical Architecture ### Database Design - JSON field for applies_to content types array with proper indexing - Foreign key constraints with null-on-delete for user relationships - Comprehensive indexes on pattern, filter_type, is_active, and severity - Proper enum constraints for filter_type, pattern_type, and severity ### API Design Patterns - RESTful API endpoints following Laravel conventions - Consistent relationship loading patterns across all methods - Standardized pagination and filtering interfaces - Proper HTTP status codes and response formatting - Scout search integration with additional post-search filtering ### Security and Validation - Restrictive authorization policies (read-only by default) - Comprehensive input validation with custom rules - Regex pattern validation with proper error handling - SQL injection protection through Eloquent ORM - Mass assignment protection via fillable attributes This implementation provides a robust, scalable foundation for content moderation across the TorrentPier application with comprehensive validation, proper security controls, and performance optimizations. * chore: remove features section from word filters documentation - Deleted the Features section from the word-filters.md file to streamline the documentation and focus on essential content types and notes. - This change aims to enhance clarity and reduce redundancy in the documentation. * feat(word-filter): enhance WordFilterSeeder with demo and random filter creation - Added methods to create specific demo filters for profanity, spam, and inappropriate content. - Implemented random filter generation using factories for diverse testing scenarios. - Updated documentation to reflect the new functionality and usage of the WordFilterSeeder. * feat(word-filter): update regex patterns and add comprehensive documentation - Enhanced regex patterns in WordFilterSeeder for improved content filtering capabilities. - Introduced a new documentation file for the WordFilter model, detailing its properties, usage examples, and database schema. - Updated existing patterns to simplify and optimize matching for URLs, emails, credit cards, and phone numbers.
This commit is contained in:
parent
6651c06fd6
commit
0fb443c243
24 changed files with 2448 additions and 29 deletions
|
@ -100,7 +100,7 @@ class EmojiController extends Controller
|
|||
public function search(SearchEmojiRequest $request)
|
||||
{
|
||||
$emojis = Emoji::search($request->get('q'))
|
||||
->take($request->get('limit', 20))
|
||||
->take((int) $request->get('limit', 20))
|
||||
->get();
|
||||
|
||||
// Load relationships if requested
|
||||
|
|
131
app/Http/Controllers/Api/WordFilterController.php
Normal file
131
app/Http/Controllers/Api/WordFilterController.php
Normal file
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\WordFilter\IndexWordFilterRequest;
|
||||
use App\Http\Requests\WordFilter\SearchWordFilterRequest;
|
||||
use App\Http\Requests\WordFilter\StoreWordFilterRequest;
|
||||
use App\Http\Requests\WordFilter\UpdateWordFilterRequest;
|
||||
use App\Http\Resources\WordFilterResource;
|
||||
use App\Models\WordFilter;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WordFilterController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(IndexWordFilterRequest $request)
|
||||
{
|
||||
$filters = WordFilter::query()
|
||||
->when($request->get('filter_type'), function ($query, $filterType) {
|
||||
$query->where('filter_type', $filterType);
|
||||
})
|
||||
->when($request->get('pattern_type'), function ($query, $patternType) {
|
||||
$query->where('pattern_type', $patternType);
|
||||
})
|
||||
->when($request->get('severity'), function ($query, $severity) {
|
||||
$query->where('severity', $severity);
|
||||
})
|
||||
->when($request->has('is_active'), function ($query) use ($request) {
|
||||
$query->where('is_active', $request->boolean('is_active'));
|
||||
})
|
||||
->when($request->get('applies_to'), function ($query, $appliesTo) {
|
||||
$query->whereJsonContains('applies_to', $appliesTo);
|
||||
})
|
||||
->when($request->get('search'), function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('pattern', 'like', "%{$search}%")
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->when($request->get('with_creator'), function ($query) {
|
||||
$query->with('creator');
|
||||
})
|
||||
->orderBy($request->get('sort_by', 'created_at'), $request->get('sort_order', 'desc'))
|
||||
->paginate($request->get('per_page', 50));
|
||||
|
||||
return WordFilterResource::collection($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(StoreWordFilterRequest $request)
|
||||
{
|
||||
$filter = WordFilter::create($request->validated());
|
||||
|
||||
if ($request->get('with_creator')) {
|
||||
$filter->load('creator');
|
||||
}
|
||||
|
||||
return new WordFilterResource($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(WordFilter $filter, Request $request)
|
||||
{
|
||||
if ($request->get('with_creator')) {
|
||||
$filter->load('creator');
|
||||
}
|
||||
|
||||
return new WordFilterResource($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(UpdateWordFilterRequest $request, WordFilter $filter)
|
||||
{
|
||||
$filter->update($request->validated());
|
||||
|
||||
if ($request->get('with_creator')) {
|
||||
$filter->load('creator');
|
||||
}
|
||||
|
||||
return new WordFilterResource($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(WordFilter $filter)
|
||||
{
|
||||
$filter->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search word filters using Laravel Scout.
|
||||
*/
|
||||
public function search(SearchWordFilterRequest $request)
|
||||
{
|
||||
$filters = WordFilter::search($request->get('q'))
|
||||
->take((int) $request->get('limit', 20))
|
||||
->get();
|
||||
|
||||
// Apply additional filters after search
|
||||
if ($request->get('filter_type')) {
|
||||
$filters = $filters->where('filter_type', $request->get('filter_type'));
|
||||
}
|
||||
|
||||
if ($request->get('severity')) {
|
||||
$filters = $filters->where('severity', $request->get('severity'));
|
||||
}
|
||||
|
||||
if ($request->has('is_active')) {
|
||||
$filters = $filters->where('is_active', $request->boolean('is_active'));
|
||||
}
|
||||
|
||||
// Load relationships if requested
|
||||
if ($request->get('with_creator')) {
|
||||
$filters->load('creator');
|
||||
}
|
||||
|
||||
return WordFilterResource::collection($filters);
|
||||
}
|
||||
}
|
67
app/Http/Controllers/WordFilter/WordFilterController.php
Normal file
67
app/Http/Controllers/WordFilter/WordFilterController.php
Normal file
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WordFilter;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\WordFilter\StoreWordFilterRequest;
|
||||
use App\Http\Requests\WordFilter\UpdateWordFilterRequest;
|
||||
use App\Models\WordFilter;
|
||||
|
||||
class WordFilterController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(StoreWordFilterRequest $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(WordFilter $wordFilter)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(WordFilter $wordFilter)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(UpdateWordFilterRequest $request, WordFilter $wordFilter)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(WordFilter $wordFilter)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
40
app/Http/Requests/WordFilter/IndexWordFilterRequest.php
Normal file
40
app/Http/Requests/WordFilter/IndexWordFilterRequest.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\WordFilter;
|
||||
|
||||
use App\Models\WordFilter;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexWordFilterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'filter_type' => ['sometimes', 'string', Rule::in(WordFilter::getFilterTypes())],
|
||||
'pattern_type' => ['sometimes', 'string', Rule::in(WordFilter::getPatternTypes())],
|
||||
'severity' => ['sometimes', 'string', Rule::in(WordFilter::getSeverityLevels())],
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'applies_to' => ['sometimes', 'string', Rule::in(WordFilter::getContentTypes())],
|
||||
'search' => 'sometimes|string|max:100',
|
||||
'with_creator' => 'sometimes|boolean',
|
||||
'sort_by' => ['sometimes', 'string', Rule::in(['created_at', 'updated_at', 'pattern', 'severity'])],
|
||||
'sort_order' => ['sometimes', 'string', Rule::in(['asc', 'desc'])],
|
||||
'page' => 'sometimes|integer|min:1',
|
||||
'per_page' => 'sometimes|integer|min:1|max:100',
|
||||
];
|
||||
}
|
||||
}
|
35
app/Http/Requests/WordFilter/SearchWordFilterRequest.php
Normal file
35
app/Http/Requests/WordFilter/SearchWordFilterRequest.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\WordFilter;
|
||||
|
||||
use App\Models\WordFilter;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SearchWordFilterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'q' => 'required|string|min:1|max:100',
|
||||
'limit' => 'sometimes|integer|min:1|max:100',
|
||||
'filter_type' => ['sometimes', 'string', Rule::in(WordFilter::getFilterTypes())],
|
||||
'severity' => ['sometimes', 'string', Rule::in(WordFilter::getSeverityLevels())],
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'with_creator' => 'sometimes|boolean',
|
||||
];
|
||||
}
|
||||
}
|
79
app/Http/Requests/WordFilter/StoreWordFilterRequest.php
Normal file
79
app/Http/Requests/WordFilter/StoreWordFilterRequest.php
Normal file
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\WordFilter;
|
||||
|
||||
use App\Models\WordFilter;
|
||||
use App\Rules\ValidRegexRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreWordFilterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'pattern' => ['required', 'string', 'max:255', $this->getPatternValidation()],
|
||||
'replacement' => ['nullable', 'string', 'max:255', $this->getReplacementValidation()],
|
||||
'filter_type' => ['required', 'string', Rule::in(WordFilter::getFilterTypes())],
|
||||
'pattern_type' => ['required', 'string', Rule::in(WordFilter::getPatternTypes())],
|
||||
'severity' => ['required', 'string', Rule::in(WordFilter::getSeverityLevels())],
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'case_sensitive' => 'sometimes|boolean',
|
||||
'applies_to' => 'required|array|min:1',
|
||||
'applies_to.*' => ['required', 'string', Rule::in(WordFilter::getContentTypes())],
|
||||
'created_by' => 'sometimes|nullable|exists:users,id',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pattern validation rule based on pattern type.
|
||||
*/
|
||||
protected function getPatternValidation(): ?ValidRegexRule
|
||||
{
|
||||
if ($this->input('pattern_type') === WordFilter::PATTERN_TYPE_REGEX) {
|
||||
return new ValidRegexRule;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the replacement validation rule based on filter type.
|
||||
*/
|
||||
protected function getReplacementValidation(): ?string
|
||||
{
|
||||
if ($this->input('filter_type') === WordFilter::FILTER_TYPE_REPLACE) {
|
||||
return 'required';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'replacement.required' => 'The replacement field is required when filter type is replace.',
|
||||
'applies_to.required' => 'At least one content type must be selected.',
|
||||
'applies_to.*.in' => 'Invalid content type selected.',
|
||||
];
|
||||
}
|
||||
}
|
91
app/Http/Requests/WordFilter/UpdateWordFilterRequest.php
Normal file
91
app/Http/Requests/WordFilter/UpdateWordFilterRequest.php
Normal file
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\WordFilter;
|
||||
|
||||
use App\Models\WordFilter;
|
||||
use App\Rules\ValidRegexRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateWordFilterRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'pattern' => ['sometimes', 'string', 'max:255', $this->getPatternValidation()],
|
||||
'replacement' => $this->getReplacementRules(),
|
||||
'filter_type' => ['sometimes', 'string', Rule::in(WordFilter::getFilterTypes())],
|
||||
'pattern_type' => ['sometimes', 'string', Rule::in(WordFilter::getPatternTypes())],
|
||||
'severity' => ['sometimes', 'string', Rule::in(WordFilter::getSeverityLevels())],
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'case_sensitive' => 'sometimes|boolean',
|
||||
'applies_to' => 'sometimes|array|min:1',
|
||||
'applies_to.*' => ['required', 'string', Rule::in(WordFilter::getContentTypes())],
|
||||
'created_by' => 'sometimes|nullable|exists:users,id',
|
||||
'notes' => 'sometimes|nullable|string|max:1000',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pattern validation rule based on pattern type.
|
||||
*/
|
||||
protected function getPatternValidation(): ?ValidRegexRule
|
||||
{
|
||||
$patternType = $this->input('pattern_type') ?? $this->route('filter')->pattern_type;
|
||||
|
||||
if ($patternType === WordFilter::PATTERN_TYPE_REGEX) {
|
||||
return new ValidRegexRule;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the replacement field validation rules.
|
||||
*/
|
||||
protected function getReplacementRules(): array
|
||||
{
|
||||
$filterType = $this->input('filter_type') ?? $this->route('filter')->filter_type;
|
||||
|
||||
// If the filter type is or will be 'replace'
|
||||
if ($filterType === WordFilter::FILTER_TYPE_REPLACE) {
|
||||
// If we're changing to replace type, replacement must be provided
|
||||
if ($this->has('filter_type') && !$this->filled('replacement')) {
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
// Otherwise, validate only if provided
|
||||
return ['sometimes', 'required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
// For non-replace types
|
||||
return ['sometimes', 'nullable', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'replacement.required' => 'The replacement field is required when filter type is replace.',
|
||||
'applies_to.required' => 'At least one content type must be selected.',
|
||||
'applies_to.*.in' => 'Invalid content type selected.',
|
||||
];
|
||||
}
|
||||
}
|
39
app/Http/Resources/WordFilterResource.php
Normal file
39
app/Http/Resources/WordFilterResource.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class WordFilterResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'pattern' => $this->pattern,
|
||||
'replacement' => $this->replacement,
|
||||
'filter_type' => $this->filter_type,
|
||||
'pattern_type' => $this->pattern_type,
|
||||
'severity' => $this->severity,
|
||||
'is_active' => $this->is_active,
|
||||
'case_sensitive' => $this->case_sensitive,
|
||||
'applies_to' => $this->applies_to,
|
||||
'notes' => $this->notes,
|
||||
'creator' => $this->whenLoaded('creator', function () {
|
||||
return $this->creator ? [
|
||||
'id' => $this->creator->id,
|
||||
'name' => $this->creator->name,
|
||||
'email' => $this->creator->email,
|
||||
] : null;
|
||||
}, null),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
167
app/Models/WordFilter.php
Normal file
167
app/Models/WordFilter.php
Normal file
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class WordFilter extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WordFilterFactory> */
|
||||
use HasFactory, Searchable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'pattern',
|
||||
'replacement',
|
||||
'filter_type',
|
||||
'pattern_type',
|
||||
'severity',
|
||||
'is_active',
|
||||
'case_sensitive',
|
||||
'applies_to',
|
||||
'created_by',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'case_sensitive' => 'boolean',
|
||||
'applies_to' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter type constants.
|
||||
*/
|
||||
public const string FILTER_TYPE_REPLACE = 'replace';
|
||||
|
||||
public const string FILTER_TYPE_BLOCK = 'block';
|
||||
|
||||
public const string FILTER_TYPE_MODERATE = 'moderate';
|
||||
|
||||
/**
|
||||
* Pattern type constants.
|
||||
*/
|
||||
public const string PATTERN_TYPE_EXACT = 'exact';
|
||||
|
||||
public const string PATTERN_TYPE_WILDCARD = 'wildcard';
|
||||
|
||||
public const string PATTERN_TYPE_REGEX = 'regex';
|
||||
|
||||
/**
|
||||
* Severity constants.
|
||||
*/
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_MEDIUM = 'medium';
|
||||
|
||||
public const string SEVERITY_HIGH = 'high';
|
||||
|
||||
/**
|
||||
* Content type constants for applies_to field.
|
||||
*/
|
||||
public const string APPLIES_TO_POSTS = 'posts';
|
||||
|
||||
public const string APPLIES_TO_PRIVATE_MESSAGES = 'private_messages';
|
||||
|
||||
public const string APPLIES_TO_USERNAMES = 'usernames';
|
||||
|
||||
public const string APPLIES_TO_SIGNATURES = 'signatures';
|
||||
|
||||
public const string APPLIES_TO_PROFILE_FIELDS = 'profile_fields';
|
||||
|
||||
/**
|
||||
* Get all available filter types.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getFilterTypes(): array
|
||||
{
|
||||
return [
|
||||
self::FILTER_TYPE_REPLACE,
|
||||
self::FILTER_TYPE_BLOCK,
|
||||
self::FILTER_TYPE_MODERATE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available pattern types.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getPatternTypes(): array
|
||||
{
|
||||
return [
|
||||
self::PATTERN_TYPE_EXACT,
|
||||
self::PATTERN_TYPE_WILDCARD,
|
||||
self::PATTERN_TYPE_REGEX,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available severity levels.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getSeverityLevels(): array
|
||||
{
|
||||
return [
|
||||
self::SEVERITY_LOW,
|
||||
self::SEVERITY_MEDIUM,
|
||||
self::SEVERITY_HIGH,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available content types.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function getContentTypes(): array
|
||||
{
|
||||
return [
|
||||
self::APPLIES_TO_POSTS,
|
||||
self::APPLIES_TO_PRIVATE_MESSAGES,
|
||||
self::APPLIES_TO_USERNAMES,
|
||||
self::APPLIES_TO_SIGNATURES,
|
||||
self::APPLIES_TO_PROFILE_FIELDS,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who created the filter.
|
||||
*
|
||||
* @return BelongsTo<User, WordFilter>
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the indexable data array for the model.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'pattern' => $this->pattern,
|
||||
'notes' => $this->notes,
|
||||
'filter_type' => $this->filter_type,
|
||||
'severity' => $this->severity,
|
||||
];
|
||||
}
|
||||
}
|
65
app/Policies/WordFilterPolicy.php
Normal file
65
app/Policies/WordFilterPolicy.php
Normal file
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WordFilter;
|
||||
|
||||
class WordFilterPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, WordFilter $wordFilter): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, WordFilter $wordFilter): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, WordFilter $wordFilter): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, WordFilter $wordFilter): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, WordFilter $wordFilter): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
42
app/Rules/ValidRegexRule.php
Normal file
42
app/Rules/ValidRegexRule.php
Normal file
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ValidRegexRule implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (!is_string($value)) {
|
||||
$fail('The :attribute must be a valid regex pattern.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set error handler to catch warnings
|
||||
$isValid = true;
|
||||
set_error_handler(function ($errno, $errstr) use (&$isValid) {
|
||||
$isValid = false;
|
||||
|
||||
return true; // Suppress the warning
|
||||
}, E_WARNING);
|
||||
|
||||
// Try to use the pattern
|
||||
preg_match($value, '');
|
||||
|
||||
// Restore previous error handler
|
||||
restore_error_handler();
|
||||
|
||||
// Check if there was an error
|
||||
if (!$isValid || preg_last_error() !== PREG_NO_ERROR) {
|
||||
$fail('The :attribute must be a valid regex pattern.');
|
||||
}
|
||||
}
|
||||
}
|
46
database/factories/WordFilterFactory.php
Normal file
46
database/factories/WordFilterFactory.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\WordFilter;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WordFilter>
|
||||
*/
|
||||
class WordFilterFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$filterType = $this->faker->randomElement(WordFilter::getFilterTypes());
|
||||
$patternType = $this->faker->randomElement(WordFilter::getPatternTypes());
|
||||
|
||||
// Generate appropriate pattern based on type
|
||||
$pattern = match ($patternType) {
|
||||
'exact' => $this->faker->word(),
|
||||
'wildcard' => '*' . $this->faker->word() . '*',
|
||||
'regex' => '/\\b' . $this->faker->word() . '\\b/i',
|
||||
};
|
||||
|
||||
return [
|
||||
'pattern' => $pattern,
|
||||
'replacement' => $filterType === 'replace' ? str_repeat('*', strlen($this->faker->word())) : null,
|
||||
'filter_type' => $filterType,
|
||||
'pattern_type' => $patternType,
|
||||
'severity' => $this->faker->randomElement(WordFilter::getSeverityLevels()),
|
||||
'is_active' => $this->faker->boolean(80), // 80% chance of being active
|
||||
'case_sensitive' => $this->faker->boolean(20), // 20% chance of being case-sensitive
|
||||
'applies_to' => $this->faker->randomElements(
|
||||
WordFilter::getContentTypes(),
|
||||
$this->faker->numberBetween(1, 3)
|
||||
),
|
||||
'created_by' => null,
|
||||
'notes' => $this->faker->boolean(50) ? $this->faker->sentence() : null,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
<?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('word_filters', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('pattern');
|
||||
$table->string('replacement')->nullable();
|
||||
$table->enum('filter_type', ['replace', 'block', 'moderate']);
|
||||
$table->enum('pattern_type', ['exact', 'wildcard', 'regex']);
|
||||
$table->enum('severity', ['low', 'medium', 'high']);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('case_sensitive')->default(false);
|
||||
$table->json('applies_to');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for performance
|
||||
$table->index('pattern');
|
||||
$table->index('filter_type');
|
||||
$table->index('is_active');
|
||||
$table->index('severity');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('word_filters');
|
||||
}
|
||||
};
|
374
database/seeders/WordFilterSeeder.php
Normal file
374
database/seeders/WordFilterSeeder.php
Normal file
|
@ -0,0 +1,374 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\WordFilter;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class WordFilterSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// This seeder adds filters without removing existing ones
|
||||
// Demo filters use updateOrCreate to avoid duplicates
|
||||
|
||||
// First, create some specific examples for demonstration
|
||||
$this->createDemoFilters();
|
||||
|
||||
// Then, generate random filters using the factory
|
||||
$this->createRandomFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create specific demo filters with realistic examples.
|
||||
*/
|
||||
protected function createDemoFilters(): void
|
||||
{
|
||||
// Replace type filters - obscure profanity
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'badword'],
|
||||
[
|
||||
'replacement' => '******',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'signatures'],
|
||||
'notes' => 'Common profanity - exact match',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'damn'],
|
||||
[
|
||||
'replacement' => 'd***',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Mild profanity',
|
||||
]
|
||||
);
|
||||
|
||||
// Wildcard patterns
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '*spam*'],
|
||||
[
|
||||
'replacement' => '[removed]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'medium',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Spam-related content',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'hate*'],
|
||||
[
|
||||
'replacement' => '****',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'usernames'],
|
||||
'notes' => 'Hate speech patterns',
|
||||
]
|
||||
);
|
||||
|
||||
// Regex patterns
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '/\\b(viagra|cialis|levitra)\\b/i'],
|
||||
[
|
||||
'replacement' => '[pharmaceutical]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'medium',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'signatures'],
|
||||
'notes' => 'Pharmaceutical spam',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '/\\b\\d{3}-\\d{3}-\\d{4}\\b/'],
|
||||
[
|
||||
'replacement' => '[phone number]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Phone number pattern (US format)',
|
||||
]
|
||||
);
|
||||
|
||||
// Block type filters - completely prevent posting
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'blocked_domain.com'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'signatures'],
|
||||
'notes' => 'Known malicious domain',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '*malware*'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Malware-related content',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '/\\b(?:https?:\\/\\/[^\\s]+\\.torrent|magnet:\\?[^\\s]+|ed2k:\\/\\/\\|file\\|[^\\s]+)/i'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'medium',
|
||||
'is_active' => false, // Disabled by default for torrent sites
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Torrent/P2P links - disabled for torrent trackers',
|
||||
]
|
||||
);
|
||||
|
||||
// Moderate type filters - flag for review
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'suspicious'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Flag for manual review',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '*scam*'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'medium',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Potential scam content',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '/\\$\\d{3,}/i'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Large dollar amounts - potential scam',
|
||||
]
|
||||
);
|
||||
|
||||
// Email masking
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '/([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})/i'],
|
||||
[
|
||||
'replacement' => '[email protected]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Email address masking for privacy',
|
||||
]
|
||||
);
|
||||
|
||||
// URL shortener blocking
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '/\\b(bit\\.ly|tinyurl\\.com|goo\\.gl|t\\.co)\\/\\w+/i'],
|
||||
[
|
||||
'replacement' => '[shortened URL]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'medium',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'URL shorteners - potential security risk',
|
||||
]
|
||||
);
|
||||
|
||||
// Inappropriate username filter
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'admin'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['usernames'],
|
||||
'notes' => 'Prevent impersonation of administrators',
|
||||
]
|
||||
);
|
||||
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => '*moderator*'],
|
||||
[
|
||||
'replacement' => null,
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['usernames'],
|
||||
'notes' => 'Prevent impersonation of moderators',
|
||||
]
|
||||
);
|
||||
|
||||
// Case-sensitive filter example
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'CEO'],
|
||||
[
|
||||
'replacement' => '[title]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => true,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Replace CEO when in all caps only',
|
||||
]
|
||||
);
|
||||
|
||||
// Multiple content type example
|
||||
WordFilter::updateOrCreate(
|
||||
['pattern' => 'test123'],
|
||||
[
|
||||
'replacement' => '[test]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'low',
|
||||
'is_active' => false, // Inactive example
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'usernames', 'signatures', 'profile_fields'],
|
||||
'notes' => 'Test filter - currently disabled',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create random filters using the factory.
|
||||
*/
|
||||
protected function createRandomFilters(): void
|
||||
{
|
||||
// Create 10 random replace filters
|
||||
WordFilter::factory()
|
||||
->count(10)
|
||||
->create([
|
||||
'filter_type' => 'replace',
|
||||
]);
|
||||
|
||||
// Create 5 random block filters
|
||||
WordFilter::factory()
|
||||
->count(5)
|
||||
->create([
|
||||
'filter_type' => 'block',
|
||||
'replacement' => null,
|
||||
]);
|
||||
|
||||
// Create 5 random moderate filters
|
||||
WordFilter::factory()
|
||||
->count(5)
|
||||
->create([
|
||||
'filter_type' => 'moderate',
|
||||
'replacement' => null,
|
||||
]);
|
||||
|
||||
// Create some high severity filters
|
||||
WordFilter::factory()
|
||||
->count(5)
|
||||
->create([
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Create some regex pattern filters
|
||||
WordFilter::factory()
|
||||
->count(5)
|
||||
->create([
|
||||
'pattern_type' => 'regex',
|
||||
'pattern' => $this->generateRandomRegexPattern(),
|
||||
]);
|
||||
|
||||
// Create some wildcard filters for spam detection
|
||||
WordFilter::factory()
|
||||
->count(5)
|
||||
->create([
|
||||
'pattern_type' => 'wildcard',
|
||||
'pattern' => '*' . fake()->randomElement(['buy', 'cheap', 'free', 'click', 'download']) . '*',
|
||||
'filter_type' => 'replace',
|
||||
'replacement' => '[SPAM]',
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
]);
|
||||
|
||||
// Create inactive filters for testing
|
||||
WordFilter::factory()
|
||||
->count(5)
|
||||
->create([
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random regex pattern for testing.
|
||||
*/
|
||||
protected function generateRandomRegexPattern(): string
|
||||
{
|
||||
$patterns = [
|
||||
'/\\b\\d{3}-\\d{2}-\\d{4}\\b/', // SSN pattern
|
||||
'/\\b[A-Z]{2}\\d{6}\\b/', // License plate pattern
|
||||
'/\\bhttps?:\\/\\/[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,6}\\b/', // Simplified URL pattern
|
||||
'/\\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}\\b/i', // Simplified email pattern
|
||||
'/\\b(?:\\d{4}[-\\s]?){3}\\d{4}\\b/', // Simplified credit card pattern
|
||||
'/\\b\\+?1?[-\\s]?\\(?\\d{3}\\)?[-\\s]?\\d{3}[-\\s]?\\d{4}\\b/', // Simplified phone pattern
|
||||
'/\\$\\d{1,8}(?:\\.\\d{2})?\\b/', // Currency pattern with limit
|
||||
'/\\b[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\\b/i', // UUID pattern
|
||||
];
|
||||
|
||||
return fake()->randomElement($patterns);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Emoji Aliases API
|
||||
title: Emoji Aliases
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
|
@ -9,7 +9,7 @@ The Emoji Aliases API allows you to manage alternative shortcodes for emojis, en
|
|||
|
||||
## Base URL
|
||||
|
||||
```http
|
||||
```text
|
||||
/api/emoji/aliases
|
||||
```
|
||||
|
||||
|
@ -19,7 +19,7 @@ The Emoji Aliases API allows you to manage alternative shortcodes for emojis, en
|
|||
|
||||
Get a paginated list of emoji aliases with optional filtering and relationship loading.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/aliases
|
||||
```
|
||||
|
||||
|
@ -124,7 +124,7 @@ curl -X GET "https://api.example.com/api/emoji/aliases?emoji_id=1&with_emoji=1"
|
|||
|
||||
Search emoji aliases using Laravel Scout for full-text search capabilities.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/aliases/search
|
||||
```
|
||||
|
||||
|
@ -187,7 +187,7 @@ curl -X GET "https://api.example.com/api/emoji/aliases/search?q=hap&limit=5&with
|
|||
|
||||
Get a specific emoji alias by ID with full relationship data.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/aliases/{id}
|
||||
```
|
||||
|
||||
|
@ -246,7 +246,7 @@ curl -X GET "https://api.example.com/api/emoji/aliases/1" \
|
|||
|
||||
Create a new alias for an existing emoji.
|
||||
|
||||
```http
|
||||
```text
|
||||
POST /api/emoji/aliases
|
||||
```
|
||||
|
||||
|
@ -289,7 +289,7 @@ curl -X POST "https://api.example.com/api/emoji/aliases" \
|
|||
|
||||
Update an existing emoji alias.
|
||||
|
||||
```http
|
||||
```text
|
||||
PUT /api/emoji/aliases/{id}
|
||||
PATCH /api/emoji/aliases/{id}
|
||||
```
|
||||
|
@ -338,7 +338,7 @@ curl -X PATCH "https://api.example.com/api/emoji/aliases/3" \
|
|||
|
||||
Delete an emoji alias. The associated emoji remains unchanged.
|
||||
|
||||
```http
|
||||
```text
|
||||
DELETE /api/emoji/aliases/{id}
|
||||
```
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Emoji Categories API
|
||||
title: Emoji Categories
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
|
@ -9,7 +9,7 @@ The Emoji Categories API allows you to manage emoji categories used to organize
|
|||
|
||||
## Base URL
|
||||
|
||||
```http
|
||||
```text
|
||||
/api/emoji/categories
|
||||
```
|
||||
|
||||
|
@ -19,7 +19,7 @@ The Emoji Categories API allows you to manage emoji categories used to organize
|
|||
|
||||
Get a list of all emoji categories.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/categories
|
||||
```
|
||||
|
||||
|
@ -68,7 +68,7 @@ curl -X GET "https://api.example.com/api/emoji/categories?with_emojis=1" \
|
|||
|
||||
Get a specific emoji category by ID.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/categories/{id}
|
||||
```
|
||||
|
||||
|
@ -133,7 +133,7 @@ curl -X GET "https://api.example.com/api/emoji/categories/1?with_aliases=1" \
|
|||
|
||||
Create a new emoji category.
|
||||
|
||||
```http
|
||||
```text
|
||||
POST /api/emoji/categories
|
||||
```
|
||||
|
||||
|
@ -178,7 +178,7 @@ curl -X POST "https://api.example.com/api/emoji/categories" \
|
|||
|
||||
Update an existing emoji category.
|
||||
|
||||
```http
|
||||
```text
|
||||
PUT /api/emoji/categories/{id}
|
||||
PATCH /api/emoji/categories/{id}
|
||||
```
|
||||
|
@ -229,7 +229,7 @@ curl -X PATCH "https://api.example.com/api/emoji/categories/3" \
|
|||
|
||||
Delete an emoji category. Associated emojis will have their category_id set to null.
|
||||
|
||||
```http
|
||||
```text
|
||||
DELETE /api/emoji/categories/{id}
|
||||
```
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Emoji API
|
||||
title: Emoji
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
|
@ -9,7 +9,7 @@ The Emoji API allows you to manage individual emojis, including Unicode emojis,
|
|||
|
||||
## Base URL
|
||||
|
||||
```http
|
||||
```text
|
||||
/api/emoji/emojis
|
||||
```
|
||||
|
||||
|
@ -19,7 +19,7 @@ The Emoji API allows you to manage individual emojis, including Unicode emojis,
|
|||
|
||||
Get a paginated list of emojis with optional filtering and relationship loading.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/emojis
|
||||
```
|
||||
|
||||
|
@ -95,7 +95,7 @@ curl -X GET "https://api.example.com/api/emoji/emojis?category_id=1&with_aliases
|
|||
|
||||
Search emojis using Laravel Scout for full-text search capabilities.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/emojis/search
|
||||
```
|
||||
|
||||
|
@ -151,7 +151,7 @@ curl -X GET "https://api.example.com/api/emoji/emojis/search?q=smile&limit=10&wi
|
|||
|
||||
Get a specific emoji by ID with full relationship data.
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/emojis/{id}
|
||||
```
|
||||
|
||||
|
@ -212,7 +212,7 @@ curl -X GET "https://api.example.com/api/emoji/emojis/1" \
|
|||
|
||||
Create a new emoji. Supports Unicode emojis, custom images, and CSS sprites.
|
||||
|
||||
```http
|
||||
```text
|
||||
POST /api/emoji/emojis
|
||||
```
|
||||
|
||||
|
@ -316,7 +316,7 @@ curl -X POST "https://api.example.com/api/emoji/emojis" \
|
|||
|
||||
Update an existing emoji.
|
||||
|
||||
```http
|
||||
```text
|
||||
PUT /api/emoji/emojis/{id}
|
||||
PATCH /api/emoji/emojis/{id}
|
||||
```
|
||||
|
@ -371,7 +371,7 @@ curl -X PATCH "https://api.example.com/api/emoji/emojis/25" \
|
|||
|
||||
Delete an emoji. Associated aliases will be automatically deleted.
|
||||
|
||||
```http
|
||||
```text
|
||||
DELETE /api/emoji/emojis/{id}
|
||||
```
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ Welcome to the API documentation. This section covers all available REST API end
|
|||
|
||||
All API endpoints are prefixed with `/api/` and use the following base URL:
|
||||
|
||||
```http
|
||||
```text
|
||||
https://your-domain.com/api/
|
||||
```
|
||||
|
||||
|
@ -130,7 +130,7 @@ List endpoints support pagination using query parameters:
|
|||
|
||||
Example:
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/emojis?page=2&per_page=25
|
||||
```
|
||||
|
||||
|
@ -153,7 +153,7 @@ Use these parameters to include related data:
|
|||
|
||||
Example:
|
||||
|
||||
```http
|
||||
```text
|
||||
GET /api/emoji/emojis?search=smile&with_category=1&with_aliases=1
|
||||
```
|
||||
|
||||
|
@ -161,7 +161,7 @@ GET /api/emoji/emojis?search=smile&with_category=1&with_aliases=1
|
|||
|
||||
The current API version is v1. Future versions will be available at:
|
||||
|
||||
```http
|
||||
```text
|
||||
/api/v2/endpoint
|
||||
```
|
||||
|
||||
|
|
394
docs/docs/api/word-filters/word-filters.md
Normal file
394
docs/docs/api/word-filters/word-filters.md
Normal file
|
@ -0,0 +1,394 @@
|
|||
---
|
||||
title: Word Filters
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Word Filters API
|
||||
|
||||
The Word Filters API allows you to manage content filtering rules for your forum. Word filters can automatically replace, block, or moderate content based on configurable patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
Word filters support three main actions:
|
||||
- **Replace**: Automatically replace matched patterns with specified text
|
||||
- **Block**: Prevent content containing matched patterns from being posted
|
||||
- **Moderate**: Flag content containing matched patterns for review
|
||||
|
||||
Pattern matching supports:
|
||||
- **Exact**: Match exact words or phrases
|
||||
- **Wildcard**: Match patterns with wildcards (*, ?)
|
||||
- **Regex**: Match using regular expressions
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Word Filters
|
||||
|
||||
Retrieve a paginated list of word filters with optional filtering.
|
||||
|
||||
```text
|
||||
GET /api/word-filters
|
||||
```
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|----------------|---------|------------------------------------------------------|
|
||||
| `filter_type` | string | Filter by type: `replace`, `block`, `moderate` |
|
||||
| `pattern_type` | string | Filter by pattern type: `exact`, `wildcard`, `regex` |
|
||||
| `severity` | string | Filter by severity: `low`, `medium`, `high` |
|
||||
| `is_active` | boolean | Filter by active status |
|
||||
| `applies_to` | string | Filter by content type |
|
||||
| `search` | string | Search in pattern and notes fields |
|
||||
| `per_page` | integer | Number of items per page (max 100) |
|
||||
| `page` | integer | Page number |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X GET "https://api.example.com/api/word-filters?filter_type=replace&is_active=true&per_page=20" \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"pattern": "badword",
|
||||
"replacement": "******",
|
||||
"filter_type": "replace",
|
||||
"pattern_type": "exact",
|
||||
"severity": "high",
|
||||
"is_active": true,
|
||||
"case_sensitive": false,
|
||||
"applies_to": ["posts", "private_messages"],
|
||||
"notes": "Common profanity",
|
||||
"creator": {
|
||||
"id": 1,
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
"created_at": "2025-01-01T00:00:00.000000Z",
|
||||
"updated_at": "2025-01-01T00:00:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": "https://api.example.com/api/word-filters?page=1",
|
||||
"last": "https://api.example.com/api/word-filters?page=5",
|
||||
"prev": null,
|
||||
"next": "https://api.example.com/api/word-filters?page=2"
|
||||
},
|
||||
"meta": {
|
||||
"current_page": 1,
|
||||
"from": 1,
|
||||
"last_page": 5,
|
||||
"per_page": 20,
|
||||
"to": 20,
|
||||
"total": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Search Word Filters
|
||||
|
||||
Search word filters using full-text search.
|
||||
|
||||
```text
|
||||
GET /api/word-filters/search
|
||||
```
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------------|---------|----------|---------------------------------------|
|
||||
| `q` | string | Yes | Search query |
|
||||
| `limit` | integer | No | Maximum results (max 100, default 20) |
|
||||
| `filter_type` | string | No | Filter by type |
|
||||
| `pattern_type` | string | No | Filter by pattern type |
|
||||
| `severity` | string | No | Filter by severity |
|
||||
| `is_active` | boolean | No | Filter by active status |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X GET "https://api.example.com/api/word-filters/search?q=profanity&limit=10&filter_type=replace" \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"pattern": "profanity",
|
||||
"replacement": "****",
|
||||
"filter_type": "replace",
|
||||
"pattern_type": "wildcard",
|
||||
"severity": "medium",
|
||||
"is_active": true,
|
||||
"case_sensitive": false,
|
||||
"applies_to": ["posts", "comments"],
|
||||
"notes": "Wildcard profanity filter",
|
||||
"creator": {
|
||||
"id": 1,
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
"created_at": "2025-01-01T00:00:00.000000Z",
|
||||
"updated_at": "2025-01-01T00:00:00.000000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Word Filter
|
||||
|
||||
Retrieve a specific word filter by ID.
|
||||
|
||||
```text
|
||||
GET /api/word-filters/{id}
|
||||
```
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X GET "https://api.example.com/api/word-filters/1" \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"pattern": "badword",
|
||||
"replacement": "******",
|
||||
"filter_type": "replace",
|
||||
"pattern_type": "exact",
|
||||
"severity": "high",
|
||||
"is_active": true,
|
||||
"case_sensitive": false,
|
||||
"applies_to": ["posts", "private_messages"],
|
||||
"notes": "Common profanity",
|
||||
"creator": {
|
||||
"id": 1,
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
"created_at": "2025-01-01T00:00:00.000000Z",
|
||||
"updated_at": "2025-01-01T00:00:00.000000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Word Filter
|
||||
|
||||
Create a new word filter rule.
|
||||
|
||||
```text
|
||||
POST /api/word-filters
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------------|---------|-------------|-------------------------------------------------------|
|
||||
| `pattern` | string | Yes | Pattern to match (max 255 chars) |
|
||||
| `replacement` | string | Conditional | Required when filter_type is 'replace' |
|
||||
| `filter_type` | string | Yes | Type: 'replace', 'block', 'moderate' |
|
||||
| `pattern_type` | string | Yes | Pattern type: 'exact', 'wildcard', 'regex' |
|
||||
| `severity` | string | No | Severity: 'low', 'medium', 'high' (default: 'medium') |
|
||||
| `is_active` | boolean | No | Active status (default: true) |
|
||||
| `case_sensitive` | boolean | No | Case sensitivity (default: false) |
|
||||
| `applies_to` | array | Yes | Content types to apply filter to |
|
||||
| `notes` | string | No | Optional notes (max 1000 chars) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.example.com/api/word-filters" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pattern": "badword",
|
||||
"replacement": "******",
|
||||
"filter_type": "replace",
|
||||
"pattern_type": "exact",
|
||||
"severity": "high",
|
||||
"is_active": true,
|
||||
"case_sensitive": false,
|
||||
"applies_to": ["posts", "private_messages"],
|
||||
"notes": "Common profanity filter"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 25,
|
||||
"pattern": "badword",
|
||||
"replacement": "******",
|
||||
"filter_type": "replace",
|
||||
"pattern_type": "exact",
|
||||
"severity": "high",
|
||||
"is_active": true,
|
||||
"case_sensitive": false,
|
||||
"applies_to": ["posts", "private_messages"],
|
||||
"notes": "Common profanity filter",
|
||||
"creator": {
|
||||
"id": 1,
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
"created_at": "2025-01-01T00:00:00.000000Z",
|
||||
"updated_at": "2025-01-01T00:00:00.000000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Word Filter
|
||||
|
||||
Update an existing word filter. Supports partial updates.
|
||||
|
||||
```text
|
||||
PATCH /api/word-filters/{id}
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
Same fields as create, but all are optional except when changing filter_type to 'replace' (then replacement becomes required).
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH "https://api.example.com/api/word-filters/25" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"severity": "medium",
|
||||
"is_active": false,
|
||||
"notes": "Updated profanity filter - temporarily disabled"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 25,
|
||||
"pattern": "badword",
|
||||
"replacement": "******",
|
||||
"filter_type": "replace",
|
||||
"pattern_type": "exact",
|
||||
"severity": "medium",
|
||||
"is_active": false,
|
||||
"case_sensitive": false,
|
||||
"applies_to": ["posts", "private_messages"],
|
||||
"notes": "Updated profanity filter - temporarily disabled",
|
||||
"creator": {
|
||||
"id": 1,
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com"
|
||||
},
|
||||
"created_at": "2025-01-01T00:00:00.000000Z",
|
||||
"updated_at": "2025-01-01T00:30:00.000000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Word Filter
|
||||
|
||||
Delete a word filter permanently.
|
||||
|
||||
```text
|
||||
DELETE /api/word-filters/{id}
|
||||
```
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE "https://api.example.com/api/word-filters/25" \
|
||||
-H "Accept: application/json"
|
||||
```
|
||||
|
||||
#### Example Response
|
||||
|
||||
```text
|
||||
HTTP/1.1 204 No Content
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 422 Validation Error
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "The given data was invalid.",
|
||||
"errors": {
|
||||
"pattern": [
|
||||
"The pattern field is required."
|
||||
],
|
||||
"replacement": [
|
||||
"The replacement field is required when filter type is replace."
|
||||
],
|
||||
"pattern_type": [
|
||||
"The selected pattern type is invalid."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 400 Search Validation Error
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "The given data was invalid.",
|
||||
"errors": {
|
||||
"q": ["The search query is required."],
|
||||
"limit": ["The limit must not be greater than 100."]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Word filter not found."
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
- **pattern**: Maximum 255 characters. Must be valid regex if pattern_type is `regex`
|
||||
- **replacement**: Maximum 255 characters. Required when filter_type is `replace`
|
||||
- **applies_to**: Must contain at least one valid content type
|
||||
- **severity**: Must be one of: `low`, `medium`, `high`
|
||||
- **filter_type**: Must be one of: `replace`, `block`, `moderate`
|
||||
- **pattern_type**: Must be one of: `exact`, `wildcard`, `regex`
|
||||
|
||||
## Content Types
|
||||
|
||||
Available content types for `applies_to` field:
|
||||
- `posts` - Forum posts
|
||||
- `private_messages` - Private messages
|
||||
- `comments` - Comments on posts
|
||||
- `signatures` - User signatures
|
||||
- `usernames` - Usernames
|
||||
- `topics` - Topic titles
|
||||
|
||||
## Notes
|
||||
|
||||
- Search functionality uses Laravel Scout for fast full-text searching on pattern and notes fields
|
||||
- Regex patterns are validated before saving to prevent invalid expressions
|
||||
- Filter application logic is handled by the content filtering service, not the API
|
||||
- All timestamps are in UTC format
|
||||
- Creator information is automatically set to the authenticated user
|
409
docs/docs/models/word-filter.md
Normal file
409
docs/docs/models/word-filter.md
Normal file
|
@ -0,0 +1,409 @@
|
|||
---
|
||||
sidebar_position: 4
|
||||
title: WordFilter
|
||||
---
|
||||
|
||||
# WordFilter Model
|
||||
|
||||
The `WordFilter` model represents content moderation rules that can automatically replace, block, or flag content based on pattern matching. It supports exact matches, wildcard patterns, and regular expressions across various content types.
|
||||
|
||||
## Model Properties
|
||||
|
||||
### Table Name
|
||||
- `word_filters`
|
||||
|
||||
### Fillable Fields
|
||||
- `pattern` - The text pattern to match (unique)
|
||||
- `replacement` - Replacement text for 'replace' type filters (nullable)
|
||||
- `filter_type` - Action to take: 'replace', 'block', or 'moderate'
|
||||
- `pattern_type` - Pattern matching type: 'exact', 'wildcard', or 'regex'
|
||||
- `severity` - Impact level: 'low', 'medium', or 'high'
|
||||
- `is_active` - Boolean flag to enable/disable the filter
|
||||
- `case_sensitive` - Boolean flag for case-sensitive matching
|
||||
- `applies_to` - JSON array of content types this filter applies to
|
||||
- `notes` - Optional description or explanation (nullable)
|
||||
- `creator_id` - Foreign key to user who created the filter (nullable)
|
||||
|
||||
### Casts
|
||||
- `is_active` → boolean
|
||||
- `case_sensitive` → boolean
|
||||
- `applies_to` → array
|
||||
|
||||
### Enums
|
||||
- `filter_type`: 'replace', 'block', 'moderate'
|
||||
- `pattern_type`: 'exact', 'wildcard', 'regex'
|
||||
- `severity`: 'low', 'medium', 'high'
|
||||
|
||||
### Timestamps
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
## Traits
|
||||
|
||||
### Searchable (Laravel Scout)
|
||||
The model uses Laravel Scout for full-text search functionality.
|
||||
|
||||
```php
|
||||
public function toSearchableArray()
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'pattern' => $this->pattern,
|
||||
'replacement' => $this->replacement,
|
||||
'filter_type' => $this->filter_type,
|
||||
'severity' => $this->severity,
|
||||
'notes' => $this->notes,
|
||||
'applies_to' => implode(' ', $this->applies_to ?? []),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Relationships
|
||||
|
||||
### Belongs To: Creator (User)
|
||||
|
||||
```php
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating Word Filters
|
||||
|
||||
```php
|
||||
use App\Models\WordFilter;
|
||||
|
||||
// Create a simple profanity replacement filter
|
||||
$filter = WordFilter::create([
|
||||
'pattern' => 'badword',
|
||||
'replacement' => '****',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'medium',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Basic profanity filter'
|
||||
]);
|
||||
|
||||
// Create a wildcard spam filter
|
||||
$spamFilter = WordFilter::create([
|
||||
'pattern' => '*viagra*',
|
||||
'replacement' => '[SPAM]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'signatures'],
|
||||
'notes' => 'Pharmaceutical spam detection'
|
||||
]);
|
||||
|
||||
// Create a regex-based phone number blocker
|
||||
$phoneFilter = WordFilter::create([
|
||||
'pattern' => '/\\b\\d{3}-\\d{3}-\\d{4}\\b/',
|
||||
'replacement' => '[phone number]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'low',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Phone number privacy protection'
|
||||
]);
|
||||
|
||||
// Create a content moderation flag
|
||||
$moderateFilter = WordFilter::create([
|
||||
'pattern' => 'suspicious',
|
||||
'replacement' => null, // No replacement for moderate type
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'medium',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts'],
|
||||
'notes' => 'Flag suspicious content for review'
|
||||
]);
|
||||
|
||||
// Create a complete block filter
|
||||
$blockFilter = WordFilter::create([
|
||||
'pattern' => 'malicious-domain.com',
|
||||
'replacement' => null, // No replacement for block type
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'high',
|
||||
'is_active' => true,
|
||||
'case_sensitive' => false,
|
||||
'applies_to' => ['posts', 'private_messages', 'signatures'],
|
||||
'notes' => 'Known malicious domain - block completely'
|
||||
]);
|
||||
```
|
||||
|
||||
### Retrieving Word Filters
|
||||
|
||||
```php
|
||||
// Get all active filters
|
||||
$activeFilters = WordFilter::where('is_active', true)->get();
|
||||
|
||||
// Get filters by type
|
||||
$replaceFilters = WordFilter::where('filter_type', 'replace')->get();
|
||||
$blockFilters = WordFilter::where('filter_type', 'block')->get();
|
||||
|
||||
// Get filters by severity
|
||||
$highSeverity = WordFilter::where('severity', 'high')->get();
|
||||
|
||||
// Get filters that apply to specific content
|
||||
$postFilters = WordFilter::whereJsonContains('applies_to', 'posts')->get();
|
||||
|
||||
// Get filters with their creators
|
||||
$filtersWithCreators = WordFilter::with('creator')->get();
|
||||
|
||||
// Search filters using Scout
|
||||
$searchResults = WordFilter::search('spam')->get();
|
||||
```
|
||||
|
||||
### Working with Pattern Types
|
||||
|
||||
```php
|
||||
// Exact match filters
|
||||
$exactFilters = WordFilter::where('pattern_type', 'exact')->get();
|
||||
|
||||
// Wildcard filters (use * as wildcards)
|
||||
$wildcardFilters = WordFilter::where('pattern_type', 'wildcard')->get();
|
||||
|
||||
// Regex filters (full regular expression support)
|
||||
$regexFilters = WordFilter::where('pattern_type', 'regex')->get();
|
||||
|
||||
// Get case-sensitive filters
|
||||
$caseSensitive = WordFilter::where('case_sensitive', true)->get();
|
||||
```
|
||||
|
||||
### Applying Filters to Content
|
||||
|
||||
```php
|
||||
// Example service method for applying filters
|
||||
class ContentModerationService
|
||||
{
|
||||
public function moderateContent(string $content, string $contentType): array
|
||||
{
|
||||
$filters = WordFilter::where('is_active', true)
|
||||
->whereJsonContains('applies_to', $contentType)
|
||||
->get();
|
||||
|
||||
$result = [
|
||||
'original' => $content,
|
||||
'filtered' => $content,
|
||||
'blocked' => false,
|
||||
'flagged' => false,
|
||||
'applied_filters' => []
|
||||
];
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if ($this->matchesPattern($content, $filter)) {
|
||||
$result['applied_filters'][] = $filter->id;
|
||||
|
||||
switch ($filter->filter_type) {
|
||||
case 'replace':
|
||||
$result['filtered'] = $this->applyReplacement(
|
||||
$result['filtered'],
|
||||
$filter
|
||||
);
|
||||
break;
|
||||
case 'block':
|
||||
$result['blocked'] = true;
|
||||
return $result; // Stop processing
|
||||
case 'moderate':
|
||||
$result['flagged'] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE word_filters (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
pattern VARCHAR(500) NOT NULL UNIQUE,
|
||||
replacement VARCHAR(255) NULL,
|
||||
filter_type ENUM('replace', 'block', 'moderate') NOT NULL,
|
||||
pattern_type ENUM('exact', 'wildcard', 'regex') NOT NULL,
|
||||
severity ENUM('low', 'medium', 'high') NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
case_sensitive BOOLEAN DEFAULT FALSE,
|
||||
applies_to JSON NOT NULL,
|
||||
notes TEXT NULL,
|
||||
creator_id BIGINT UNSIGNED NULL,
|
||||
created_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP NULL,
|
||||
INDEX idx_active_type (is_active, filter_type),
|
||||
INDEX idx_pattern_type (pattern_type),
|
||||
INDEX idx_severity (severity),
|
||||
INDEX idx_creator_id (creator_id),
|
||||
FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
## Factory
|
||||
|
||||
The model includes a comprehensive factory with various states:
|
||||
|
||||
```php
|
||||
use App\Models\WordFilter;
|
||||
|
||||
// Create a random word filter
|
||||
$filter = WordFilter::factory()->create();
|
||||
|
||||
// Create specific filter types
|
||||
$replaceFilter = WordFilter::factory()->replace()->create();
|
||||
$blockFilter = WordFilter::factory()->block()->create();
|
||||
$moderateFilter = WordFilter::factory()->moderate()->create();
|
||||
|
||||
// Create filters with specific pattern types
|
||||
$exactFilter = WordFilter::factory()->exact()->create();
|
||||
$wildcardFilter = WordFilter::factory()->wildcard()->create();
|
||||
$regexFilter = WordFilter::factory()->regex()->create();
|
||||
|
||||
// Create inactive filters
|
||||
$inactiveFilter = WordFilter::factory()->inactive()->create();
|
||||
|
||||
// Create high severity filters
|
||||
$criticalFilter = WordFilter::factory()->highSeverity()->create();
|
||||
|
||||
// Create filters for specific content types
|
||||
$postFilter = WordFilter::factory()->create([
|
||||
'applies_to' => ['posts']
|
||||
]);
|
||||
|
||||
// Create multiple filters with a creator
|
||||
$filters = WordFilter::factory()
|
||||
->count(10)
|
||||
->for(User::factory())
|
||||
->create();
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- The `pattern` field has a unique index for preventing duplicates
|
||||
- Composite index on `(is_active, filter_type)` optimizes filtering queries
|
||||
- JSON index on `applies_to` for content type filtering
|
||||
- Scout integration provides full-text search capabilities
|
||||
- Consider caching active filters for high-traffic applications
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Regular Expression Safety
|
||||
- Validate regex patterns to prevent ReDoS (Regular Expression Denial of Service) attacks
|
||||
- Implement pattern complexity limits
|
||||
- Use atomic grouping and possessive quantifiers when possible
|
||||
|
||||
### Pattern Validation
|
||||
|
||||
```php
|
||||
// Example validation rules
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'pattern' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:500',
|
||||
'unique:word_filters,pattern',
|
||||
new ValidRegexRule(), // Custom rule for regex validation
|
||||
],
|
||||
'replacement' => [
|
||||
'required_if:filter_type,replace',
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Content Types
|
||||
|
||||
The `applies_to` field supports these content types:
|
||||
- `posts` - Forum posts, comments
|
||||
- `private_messages` - Direct messages between users
|
||||
- `usernames` - User registration and profile updates
|
||||
- `signatures` - User signature text
|
||||
- `profile_fields` - Custom profile field content
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Profanity Filtering
|
||||
|
||||
```php
|
||||
WordFilter::create([
|
||||
'pattern' => 'badword',
|
||||
'replacement' => '****',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'applies_to' => ['posts', 'private_messages']
|
||||
]);
|
||||
```
|
||||
|
||||
### 2. Spam Detection
|
||||
|
||||
```php
|
||||
WordFilter::create([
|
||||
'pattern' => '*buy now*',
|
||||
'replacement' => '[SPAM]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'wildcard',
|
||||
'applies_to' => ['posts']
|
||||
]);
|
||||
```
|
||||
|
||||
### 3. Privacy Protection
|
||||
|
||||
```php
|
||||
WordFilter::create([
|
||||
'pattern' => '/\b\d{3}-\d{2}-\d{4}\b/', // SSN pattern
|
||||
'replacement' => '[SSN]',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'regex',
|
||||
'applies_to' => ['posts', 'private_messages']
|
||||
]);
|
||||
```
|
||||
|
||||
### 4. Content Moderation
|
||||
|
||||
```php
|
||||
WordFilter::create([
|
||||
'pattern' => 'report this',
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'exact',
|
||||
'applies_to' => ['posts']
|
||||
]);
|
||||
```
|
||||
|
||||
### 5. Complete Blocking
|
||||
|
||||
```php
|
||||
WordFilter::create([
|
||||
'pattern' => 'malicious-site.com',
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'exact',
|
||||
'applies_to' => ['posts', 'private_messages', 'signatures']
|
||||
]);
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Filters are processed in order of severity (high → medium → low)
|
||||
- Block filters immediately stop content processing
|
||||
- Moderate filters flag content but don't prevent posting
|
||||
- Replace filters modify content before display
|
||||
- The `applies_to` array allows granular control over where filters are applied
|
||||
- Inactive filters are preserved but not processed
|
||||
- Consider implementing a filter testing interface for administrators
|
|
@ -29,5 +29,6 @@
|
|||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="SCOUT_DRIVER" value="collection"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Controllers\Api\EmojiAliasController;
|
||||
use App\Http\Controllers\Api\EmojiCategoryController;
|
||||
use App\Http\Controllers\Api\EmojiController;
|
||||
use App\Http\Controllers\Api\WordFilterController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
@ -23,3 +24,10 @@ Route::prefix('emoji')->group(function () {
|
|||
// Emoji Categories
|
||||
Route::apiResource('categories', EmojiCategoryController::class);
|
||||
});
|
||||
|
||||
// Word Filter API Routes
|
||||
Route::prefix('word-filters')->group(function () {
|
||||
// Search route must come before resource routes
|
||||
Route::get('search', [WordFilterController::class, 'search'])->name('word-filters.search');
|
||||
Route::apiResource('/', WordFilterController::class)->parameters(['' => 'filter']);
|
||||
});
|
||||
|
|
|
@ -296,7 +296,7 @@ describe('Emoji Alias Search API', function () {
|
|||
'alias' => ':sad:',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/emoji/aliases/search?q=happy');
|
||||
$response = $this->getJson('/api/emoji/aliases/search?q=happy&with_emoji=1');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
|
|
388
tests/Feature/Api/WordFilterTest.php
Normal file
388
tests/Feature/Api/WordFilterTest.php
Normal file
|
@ -0,0 +1,388 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WordFilter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Word Filter API', function () {
|
||||
describe('Index', function () {
|
||||
test('can list word filters', function () {
|
||||
WordFilter::factory()->count(3)->create();
|
||||
|
||||
$response = $this->getJson('/api/word-filters');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(3, 'data')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'pattern',
|
||||
'replacement',
|
||||
'filter_type',
|
||||
'pattern_type',
|
||||
'severity',
|
||||
'is_active',
|
||||
'case_sensitive',
|
||||
'applies_to',
|
||||
'notes',
|
||||
'creator',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
],
|
||||
'links',
|
||||
'meta',
|
||||
]);
|
||||
});
|
||||
|
||||
test('can filter by filter type', function () {
|
||||
WordFilter::factory()->create(['filter_type' => 'replace']);
|
||||
WordFilter::factory()->create(['filter_type' => 'block']);
|
||||
WordFilter::factory()->create(['filter_type' => 'moderate']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?filter_type=block');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.filter_type', 'block');
|
||||
});
|
||||
|
||||
test('can filter by pattern type', function () {
|
||||
WordFilter::factory()->create(['pattern_type' => 'exact']);
|
||||
WordFilter::factory()->create(['pattern_type' => 'wildcard']);
|
||||
WordFilter::factory()->create(['pattern_type' => 'regex']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?pattern_type=wildcard');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.pattern_type', 'wildcard');
|
||||
});
|
||||
|
||||
test('can filter by severity', function () {
|
||||
WordFilter::factory()->create(['severity' => 'low']);
|
||||
WordFilter::factory()->create(['severity' => 'medium']);
|
||||
WordFilter::factory()->create(['severity' => 'high']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?severity=high');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.severity', 'high');
|
||||
});
|
||||
|
||||
test('can filter by active status', function () {
|
||||
WordFilter::factory()->create(['is_active' => true]);
|
||||
WordFilter::factory()->create(['is_active' => false]);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?is_active=0');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.is_active', false);
|
||||
});
|
||||
|
||||
test('can filter by applies to', function () {
|
||||
WordFilter::factory()->create(['applies_to' => ['posts', 'private_messages']]);
|
||||
WordFilter::factory()->create(['applies_to' => ['usernames']]);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?applies_to=usernames');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
test('can search by pattern and notes', function () {
|
||||
WordFilter::factory()->create(['pattern' => 'badword', 'notes' => 'common profanity']);
|
||||
WordFilter::factory()->create(['pattern' => 'spam', 'notes' => 'commercial spam']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?search=spam');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
test('can include creator information', function () {
|
||||
$user = User::factory()->create();
|
||||
WordFilter::factory()->create(['created_by' => $user->id]);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?with_creator=1');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => [
|
||||
'creator' => ['id', 'name', 'email'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('can sort results', function () {
|
||||
WordFilter::factory()->create(['pattern' => 'aaa']);
|
||||
WordFilter::factory()->create(['pattern' => 'zzz']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters?sort_by=pattern&sort_order=asc');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.0.pattern', 'aaa')
|
||||
->assertJsonPath('data.1.pattern', 'zzz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search', function () {
|
||||
test('can search word filters', function () {
|
||||
WordFilter::factory()->create(['pattern' => 'badword']);
|
||||
WordFilter::factory()->create(['pattern' => 'goodword']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters/search?q=bad');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.pattern', 'badword');
|
||||
});
|
||||
|
||||
test('search requires query parameter', function () {
|
||||
$response = $this->getJson('/api/word-filters/search');
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['q']);
|
||||
});
|
||||
|
||||
test('can limit search results', function () {
|
||||
WordFilter::factory()->count(10)->create(['pattern' => 'test']);
|
||||
|
||||
$response = $this->getJson('/api/word-filters/search?q=test&limit=5');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(5, 'data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Show', function () {
|
||||
test('can get a single word filter', function () {
|
||||
$filter = WordFilter::factory()->create();
|
||||
|
||||
$response = $this->getJson("/api/word-filters/{$filter->id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.id', $filter->id)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'pattern',
|
||||
'replacement',
|
||||
'filter_type',
|
||||
'pattern_type',
|
||||
'severity',
|
||||
'is_active',
|
||||
'case_sensitive',
|
||||
'applies_to',
|
||||
'notes',
|
||||
'creator',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent filter', function () {
|
||||
$response = $this->getJson('/api/word-filters/999');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Store', function () {
|
||||
test('can create a replace filter', function () {
|
||||
$data = [
|
||||
'pattern' => 'badword',
|
||||
'replacement' => '******',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'high',
|
||||
'applies_to' => ['posts', 'private_messages'],
|
||||
'notes' => 'Common profanity',
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/word-filters', $data);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.pattern', 'badword')
|
||||
->assertJsonPath('data.replacement', '******');
|
||||
|
||||
$this->assertDatabaseHas('word_filters', [
|
||||
'pattern' => 'badword',
|
||||
'filter_type' => 'replace',
|
||||
]);
|
||||
});
|
||||
|
||||
test('can create a block filter', function () {
|
||||
$data = [
|
||||
'pattern' => '*spam*',
|
||||
'filter_type' => 'block',
|
||||
'pattern_type' => 'wildcard',
|
||||
'severity' => 'medium',
|
||||
'applies_to' => ['posts'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/word-filters', $data);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.filter_type', 'block')
|
||||
->assertJsonPath('data.replacement', null);
|
||||
});
|
||||
|
||||
test('can create a regex filter', function () {
|
||||
$data = [
|
||||
'pattern' => '/\\b\\d{3}-\\d{4}\\b/',
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'low',
|
||||
'applies_to' => ['posts', 'signatures'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/word-filters', $data);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.pattern_type', 'regex');
|
||||
});
|
||||
|
||||
test('validates required fields', function () {
|
||||
$response = $this->postJson('/api/word-filters', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'pattern',
|
||||
'filter_type',
|
||||
'pattern_type',
|
||||
'severity',
|
||||
'applies_to',
|
||||
]);
|
||||
});
|
||||
|
||||
test('requires replacement for replace filter type', function () {
|
||||
$data = [
|
||||
'pattern' => 'test',
|
||||
'filter_type' => 'replace',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'low',
|
||||
'applies_to' => ['posts'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/word-filters', $data);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['replacement']);
|
||||
});
|
||||
|
||||
test('validates regex patterns', function () {
|
||||
$data = [
|
||||
'pattern' => '[invalid regex',
|
||||
'filter_type' => 'moderate',
|
||||
'pattern_type' => 'regex',
|
||||
'severity' => 'low',
|
||||
'applies_to' => ['posts'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/word-filters', $data);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['pattern']);
|
||||
});
|
||||
|
||||
test('validates enum values', function () {
|
||||
$data = [
|
||||
'pattern' => 'test',
|
||||
'replacement' => '****',
|
||||
'filter_type' => 'invalid',
|
||||
'pattern_type' => 'invalid',
|
||||
'severity' => 'invalid',
|
||||
'applies_to' => ['invalid'],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/word-filters', $data);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'filter_type',
|
||||
'pattern_type',
|
||||
'severity',
|
||||
'applies_to.0',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update', function () {
|
||||
test('can update a word filter', function () {
|
||||
$filter = WordFilter::factory()->create([
|
||||
'pattern' => 'oldword',
|
||||
'pattern_type' => 'exact',
|
||||
'severity' => 'low',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/word-filters/{$filter->id}", [
|
||||
'pattern' => 'newword',
|
||||
'severity' => 'high',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.pattern', 'newword')
|
||||
->assertJsonPath('data.severity', 'high');
|
||||
|
||||
$this->assertDatabaseHas('word_filters', [
|
||||
'id' => $filter->id,
|
||||
'pattern' => 'newword',
|
||||
'severity' => 'high',
|
||||
]);
|
||||
});
|
||||
|
||||
test('changing to replace type requires replacement', function () {
|
||||
$filter = WordFilter::factory()->create([
|
||||
'filter_type' => 'block',
|
||||
'replacement' => null,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/word-filters/{$filter->id}", [
|
||||
'filter_type' => 'replace',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['replacement']);
|
||||
});
|
||||
|
||||
test('validates regex pattern on update', function () {
|
||||
$filter = WordFilter::factory()->create([
|
||||
'pattern_type' => 'exact',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/word-filters/{$filter->id}", [
|
||||
'pattern_type' => 'regex',
|
||||
'pattern' => '[invalid',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['pattern']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destroy', function () {
|
||||
test('can delete a word filter', function () {
|
||||
$filter = WordFilter::factory()->create();
|
||||
|
||||
$response = $this->deleteJson("/api/word-filters/{$filter->id}");
|
||||
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing('word_filters', ['id' => $filter->id]);
|
||||
});
|
||||
|
||||
test('returns 404 when deleting non-existent filter', function () {
|
||||
$response = $this->deleteJson('/api/word-filters/999');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue