Files
portal/app/app/Http/Controllers/Api/SupplierWebhookController.php
T
Дмитрий 3bb2bf92e2 feat(supplier-webhook): accept non-B-prefix projects as platform=DIRECT
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>
2026-05-25 17:59:04 +03:00

194 lines
8.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
}