События по умолчанию для новой подписки. */ private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed']; public function show(Request $request): JsonResponse { $sub = $this->currentSubscription($request); if ($sub === null) { return response()->json(['data' => null]); } return response()->json(['data' => [ 'target_url' => $sub->target_url, 'secret_prefix' => $sub->secret_prefix, 'events' => $sub->events, 'is_active' => $sub->is_active, ]]); } public function update(Request $request): JsonResponse { $validated = $request->validate([ 'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'], ]); $sub = $this->currentSubscription($request); $plainSecret = null; if ($sub === null) { $plainSecret = self::SECRET_PREFIX.Str::random(40); $sub = OutboundWebhookSubscription::query()->create([ 'tenant_id' => (int) $request->user()->tenant_id, 'user_id' => (int) $request->user()->id, 'name' => 'Webhook', 'target_url' => $validated['target_url'], 'secret_hash' => Hash::make($plainSecret), 'secret_prefix' => substr($plainSecret, 0, 10), 'events' => self::DEFAULT_EVENTS, 'is_active' => true, ]); } else { $sub->update(['target_url' => $validated['target_url']]); } $payload = [ 'target_url' => $sub->target_url, 'secret_prefix' => $sub->secret_prefix, 'events' => $sub->events, 'is_active' => $sub->is_active, ]; if ($plainSecret !== null) { $payload['secret'] = $plainSecret; } return response()->json(['data' => $payload]); } public function test(Request $request): JsonResponse { $sub = $this->currentSubscription($request); if ($sub === null) { return response()->json([ 'message' => 'Сначала сохраните URL webhook.', ], Response::HTTP_UNPROCESSABLE_ENTITY); } $testPayload = [ 'event' => 'webhook.test', 'sent_at' => now()->toIso8601String(), 'message' => 'Тестовая доставка webhook от Лидерра.', ]; // MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных // IP) — пост-MVP security-review; URL уже ограничен https:// валидацией. try { $response = Http::timeout(10) ->withHeaders(['X-Webhook-Event' => 'webhook.test']) ->post($sub->target_url, $testPayload); return response()->json([ 'ok' => $response->successful(), 'status' => $response->status(), 'message' => $response->successful() ? "Тестовый запрос доставлен (HTTP {$response->status()})." : "Endpoint ответил HTTP {$response->status()}.", ]); } catch (\Throwable $e) { return response()->json([ 'ok' => false, 'status' => null, 'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(), ]); } } private function currentSubscription(Request $request): ?OutboundWebhookSubscription { $tenantId = (int) $request->user()->tenant_id; // Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS. return OutboundWebhookSubscription::query() ->where('tenant_id', $tenantId) ->orderByDesc('id') ->first(); } }