200 lines
6.7 KiB
PHP
200 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Moderation;
|
|
|
|
use App\Enums\ModerationActionType;
|
|
use App\Enums\ModerationDomainStatus;
|
|
use App\Models\ContentModerationActionLog;
|
|
use App\Models\ContentModerationDomain;
|
|
use App\Models\ContentModerationFinding;
|
|
use App\Models\User;
|
|
|
|
class DomainReputationService
|
|
{
|
|
public function __construct(
|
|
private readonly DomainIntelligenceService $intelligence,
|
|
) {
|
|
}
|
|
|
|
public function normalizeDomain(?string $domain): ?string
|
|
{
|
|
if (! is_string($domain)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = trim(mb_strtolower($domain));
|
|
$normalized = preg_replace('/^www\./', '', $normalized);
|
|
|
|
return $normalized !== '' ? $normalized : null;
|
|
}
|
|
|
|
public function statusForDomain(string $domain): ModerationDomainStatus
|
|
{
|
|
$normalized = $this->normalizeDomain($domain);
|
|
if ($normalized === null) {
|
|
return ModerationDomainStatus::Neutral;
|
|
}
|
|
|
|
$record = ContentModerationDomain::query()->where('domain', $normalized)->first();
|
|
if ($record !== null) {
|
|
return $record->status;
|
|
}
|
|
|
|
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.allowed_domains', []))) {
|
|
return ModerationDomainStatus::Allowed;
|
|
}
|
|
|
|
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.blacklisted_domains', []))) {
|
|
return ModerationDomainStatus::Blocked;
|
|
}
|
|
|
|
if ($this->matchesAnyPattern($normalized, (array) \app('config')->get('content_moderation.suspicious_domains', []))) {
|
|
return ModerationDomainStatus::Suspicious;
|
|
}
|
|
|
|
return ModerationDomainStatus::Neutral;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $domains
|
|
* @return array<int, ContentModerationDomain>
|
|
*/
|
|
public function trackDomains(array $domains, bool $flagged = false, bool $confirmedSpam = false): array
|
|
{
|
|
$normalized = \collect($domains)
|
|
->map(fn (?string $domain): ?string => $this->normalizeDomain($domain))
|
|
->filter()
|
|
->unique()
|
|
->values();
|
|
|
|
if ($normalized->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$existing = ContentModerationDomain::query()
|
|
->whereIn('domain', $normalized->all())
|
|
->get()
|
|
->keyBy('domain');
|
|
|
|
$records = [];
|
|
$now = \now();
|
|
|
|
foreach ($normalized as $domain) {
|
|
$defaultStatus = $this->statusForDomain($domain);
|
|
$record = $existing[$domain] ?? new ContentModerationDomain([
|
|
'domain' => $domain,
|
|
'status' => $defaultStatus,
|
|
'first_seen_at' => $now,
|
|
]);
|
|
|
|
$record->forceFill([
|
|
'status' => $record->status ?? $defaultStatus,
|
|
'times_seen' => ((int) $record->times_seen) + 1,
|
|
'times_flagged' => ((int) $record->times_flagged) + ($flagged ? 1 : 0),
|
|
'times_confirmed_spam' => ((int) $record->times_confirmed_spam) + ($confirmedSpam ? 1 : 0),
|
|
'first_seen_at' => $record->first_seen_at ?? $now,
|
|
'last_seen_at' => $now,
|
|
])->save();
|
|
|
|
$records[] = $record->fresh();
|
|
}
|
|
|
|
return $records;
|
|
}
|
|
|
|
public function updateStatus(string $domain, ModerationDomainStatus $status, ?User $actor = null, ?string $notes = null): ContentModerationDomain
|
|
{
|
|
$normalized = $this->normalizeDomain($domain);
|
|
\abort_unless($normalized !== null, 422, 'Invalid domain.');
|
|
|
|
$record = ContentModerationDomain::query()->firstOrNew(['domain' => $normalized]);
|
|
$previous = $record->status?->value;
|
|
|
|
$record->forceFill([
|
|
'status' => $status,
|
|
'first_seen_at' => $record->first_seen_at ?? \now(),
|
|
'last_seen_at' => \now(),
|
|
'notes' => $notes !== null && trim($notes) !== '' ? trim($notes) : $record->notes,
|
|
])->save();
|
|
|
|
ContentModerationActionLog::query()->create([
|
|
'target_type' => 'domain',
|
|
'target_id' => $record->id,
|
|
'action_type' => match ($status) {
|
|
ModerationDomainStatus::Blocked => ModerationActionType::BlockDomain->value,
|
|
ModerationDomainStatus::Suspicious => ModerationActionType::MarkDomainSuspicious->value,
|
|
ModerationDomainStatus::Escalated => ModerationActionType::Escalate->value,
|
|
ModerationDomainStatus::ReviewRequired => ModerationActionType::MarkDomainSuspicious->value,
|
|
ModerationDomainStatus::Allowed, ModerationDomainStatus::Neutral => ModerationActionType::AllowDomain->value,
|
|
},
|
|
'actor_type' => $actor ? 'admin' : 'system',
|
|
'actor_id' => $actor?->id,
|
|
'notes' => $notes,
|
|
'old_status' => $previous,
|
|
'new_status' => $status->value,
|
|
'meta_json' => ['domain' => $normalized],
|
|
'created_at' => \now(),
|
|
]);
|
|
|
|
$this->intelligence->refreshDomain($normalized);
|
|
|
|
return $record->fresh();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function shortenerDomains(): array
|
|
{
|
|
return \collect((array) \app('config')->get('content_moderation.shortener_domains', []))
|
|
->map(fn (string $domain): ?string => $this->normalizeDomain($domain))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function attachDomainIds(ContentModerationFinding $finding): void
|
|
{
|
|
$domains = \collect((array) $finding->matched_domains_json)
|
|
->map(fn (?string $domain): ?string => $this->normalizeDomain($domain))
|
|
->filter()
|
|
->unique()
|
|
->values();
|
|
|
|
if ($domains->isEmpty()) {
|
|
$finding->forceFill(['domain_ids_json' => []])->save();
|
|
return;
|
|
}
|
|
|
|
$ids = ContentModerationDomain::query()
|
|
->whereIn('domain', $domains->all())
|
|
->pluck('id')
|
|
->map(static fn (int $id): int => $id)
|
|
->values()
|
|
->all();
|
|
|
|
$finding->forceFill(['domain_ids_json' => $ids])->save();
|
|
|
|
foreach ($domains as $domain) {
|
|
$this->intelligence->refreshDomain((string) $domain);
|
|
}
|
|
}
|
|
|
|
private function matchesAnyPattern(string $domain, array $patterns): bool
|
|
{
|
|
foreach ($patterns as $pattern) {
|
|
$pattern = trim(mb_strtolower((string) $pattern));
|
|
if ($pattern === '') {
|
|
continue;
|
|
}
|
|
|
|
$quoted = preg_quote($pattern, '/');
|
|
$regex = '/^' . str_replace('\\*', '.*', $quoted) . '$/i';
|
|
if (preg_match($regex, $domain) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
} |