3bb2bf92e2
Drops regex /^B[123]_.+$/ from project field validation; parsePlatform() returns 'DIRECT' for projects without B-prefix (instead of silent fallback to 'B1'). SupplierProjectResolver ALLOWED_PLATFORMS extended to include DIRECT. Closes ~67 of 82 lost leads/day for tenant client1 (observed 2026-05-25): mostly client.carmoney.ru (55), B2_Caranga (7), cabinet.caranga.ru (3), cashmotor.ru (2), numeric callback IDs (~10). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
194 lines
8.1 KiB
PHP
194 lines
8.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Jobs\RouteSupplierLeadJob;
|
||
use App\Models\SupplierLead;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\RateLimiter;
|
||
use Symfony\Component\HttpFoundation\IpUtils;
|
||
|
||
/**
|
||
* Платформенный endpoint для входящего stream'а лидов от crm.bp-gr.ru.
|
||
*
|
||
* URL: POST /api/webhook/supplier/{secret}
|
||
*
|
||
* Защита (defense-in-depth, spec §5.1):
|
||
* 1. {secret} в URL — platform-wide токен (≥32 chars), хранится в
|
||
* system_settings.supplier_webhook_secret. hash_equals для сравнения.
|
||
* 2. IP allowlist (system_settings.supplier_ip_allowlist) — JSON array IP/CIDR.
|
||
* Пустой массив = пропускать всех (DEV mode); на prod заполнить.
|
||
*
|
||
* Несовпадение secret OR IP → 404 (не палим существование endpoint'а).
|
||
*
|
||
* Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
|
||
* 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
|
||
*
|
||
* Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant
|
||
* webhook был удалён вместе с ProcessWebhookJob).
|
||
*
|
||
* Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
|
||
* production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
|
||
* env=production AND allowlist пустой). На dev/testing — fail-open для localhost
|
||
* development. Admin ОБЯЗАН заполнить allowlist реальными IP/CIDR поставщика
|
||
* перед production deploy. Дополнительно: hash_equals на уровне `verifySecret`
|
||
* timing-safe только при равной длине; rate-limit per-IP добавится в Plan 3+.
|
||
*/
|
||
class SupplierWebhookController extends Controller
|
||
{
|
||
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
|
||
private const RATE_LIMIT_PER_MINUTE = 600;
|
||
|
||
public function receive(Request $request, string $secret): JsonResponse
|
||
{
|
||
if (! $this->verifySecret($secret)) {
|
||
$this->logSupplierWebhook($request, null, 'rejected_secret');
|
||
|
||
return response()->json(['message' => 'Not found.'], 404);
|
||
}
|
||
|
||
if (! $this->verifyIpAllowlist($request->ip())) {
|
||
$this->logSupplierWebhook($request, null, 'rejected_ip');
|
||
|
||
return response()->json(['message' => 'Not found.'], 404);
|
||
}
|
||
|
||
// Audit-fix C2: per-IP rate-limit. Endpoint secret-gated, но защищаем
|
||
// от flood даже с валидным secret (DoS-guard). Лимит с запасом для
|
||
// легитимного stream'а лидов от crm.bp-gr.ru.
|
||
$rateKey = 'supplier-webhook:'.($request->ip() ?? 'unknown');
|
||
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
|
||
$retryAfter = RateLimiter::availableIn($rateKey);
|
||
$this->logSupplierWebhook($request, null, 'rate_limited');
|
||
|
||
return response()->json([
|
||
'message' => 'Превышен лимит запросов.',
|
||
'retry_after' => $retryAfter,
|
||
], 429)->header('Retry-After', (string) $retryAfter);
|
||
}
|
||
RateLimiter::hit($rateKey, 60);
|
||
|
||
// Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
|
||
// (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
|
||
// "no partition of relation deals found for row" в RouteSupplierLeadJob.
|
||
// Окно ±24h защищает от wildly out-of-range значений (старый/будущий
|
||
// дроп от поставщика); покрывает retry-задержки + clock-drift серверов.
|
||
$minTime = now()->subDay()->getTimestamp();
|
||
$maxTime = now()->addDay()->getTimestamp();
|
||
|
||
$validated = $request->validate([
|
||
'vid' => 'required|integer|min:1',
|
||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||
'tag' => 'nullable|string|max:255',
|
||
'phones' => 'nullable|array',
|
||
'phones.*' => 'string|regex:/^7\d{10}$/',
|
||
]);
|
||
|
||
$existing = SupplierLead::query()->where('vid', $validated['vid'])->first();
|
||
if ($existing !== null) {
|
||
return response()->json([
|
||
'status' => 'already_processed',
|
||
'supplier_lead_id' => $existing->id,
|
||
], 200);
|
||
}
|
||
|
||
$platform = $this->parsePlatform($validated['project']);
|
||
|
||
$lead = SupplierLead::create([
|
||
'platform' => $platform,
|
||
'raw_payload' => $validated,
|
||
'vid' => $validated['vid'],
|
||
'phone' => $validated['phone'],
|
||
'received_at' => now(),
|
||
'source' => 'webhook',
|
||
]);
|
||
|
||
RouteSupplierLeadJob::dispatch($lead->id);
|
||
$this->logSupplierWebhook($request, $lead->id, 'received');
|
||
|
||
return response()->json([
|
||
'status' => 'accepted',
|
||
'supplier_lead_id' => $lead->id,
|
||
], 202);
|
||
}
|
||
|
||
/**
|
||
* Audit-fix: log all supplier webhook outcomes to webhook_log.
|
||
* Covers 4 exit points: received / rejected_secret / rejected_ip / rate_limited.
|
||
* Silently skips if table does not exist (safe for migrations in progress).
|
||
*/
|
||
private function logSupplierWebhook(Request $request, ?int $leadId, string $status): void
|
||
{
|
||
if (! \Schema::hasTable('webhook_log')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
DB::table('webhook_log')->insert([
|
||
'tenant_id' => null,
|
||
'raw_payload' => '{}',
|
||
'source' => 'supplier',
|
||
'status' => $status,
|
||
'lead_id' => $leadId,
|
||
'ip_address' => $request->ip(),
|
||
'created_at' => now(),
|
||
]);
|
||
} catch (\Throwable) {
|
||
// Never let logging failure break the primary response
|
||
}
|
||
}
|
||
|
||
private function verifySecret(string $providedSecret): bool
|
||
{
|
||
$row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
|
||
if ($row === null) {
|
||
return false;
|
||
}
|
||
$expected = (string) $row->value;
|
||
if ($expected === '__SET_ON_DEPLOY__' || strlen($expected) < 32) {
|
||
return false;
|
||
}
|
||
|
||
return hash_equals($expected, $providedSecret);
|
||
}
|
||
|
||
private function verifyIpAllowlist(?string $clientIp): bool
|
||
{
|
||
if ($clientIp === null) {
|
||
return false;
|
||
}
|
||
$row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first();
|
||
if ($row === null) {
|
||
return true;
|
||
}
|
||
$list = json_decode((string) $row->value, true) ?: [];
|
||
if ($list === []) {
|
||
// Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита
|
||
// от забытого override schema seed); dev/testing — fail-open для localhost
|
||
// development. CV.11 audit WARN #5: inline-warning lines 34-39 признавал
|
||
// проблему, теперь enforced на уровне env.
|
||
return ! app()->environment('production');
|
||
}
|
||
|
||
return IpUtils::checkIp($clientIp, $list);
|
||
}
|
||
|
||
private function parsePlatform(string $project): string
|
||
{
|
||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||
// приводил к неверной маршрутизации).
|
||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||
return $m[1];
|
||
}
|
||
|
||
return 'DIRECT';
|
||
}
|
||
}
|