Files
SkinbaseNova/app/Services/Moderation/DomainReputationService.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;
}
}