From e29a51e7ea288be35bf7dc267027db7530f05e2c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 29 Apr 2026 13:27:04 +0300 Subject: [PATCH] Harden auth and delivery endpoints --- .env.example | 6 + docs/security-audit-remediation-plan.md | 403 ++++++++++++++++++ src/context/AuthContext.jsx | 35 +- supabase/functions/README.md | 14 + .../_shared/delivery-invitations.test.ts | 49 +++ .../functions/_shared/delivery-invitations.ts | 70 +++ .../functions/_shared/integration-events.ts | 2 +- supabase/functions/_shared/security.ts | 370 ++++++++++++++++ supabase/functions/chatbot-webhook/index.ts | 71 ++- .../confirm-delivery-choice/index.ts | 176 +++++--- .../create-delivery-invitation/index.ts | 111 ++--- .../get-delivery-invitation/index.ts | 173 ++++---- .../functions/report-delivery-result/index.ts | 86 ++-- supabase/functions/request-otp/index.ts | 80 ++++ .../functions/send-chatbot-message/index.ts | 33 +- .../functions/transfer-to-logistics/index.ts | 86 ++-- supabase/functions/verify-otp/index.ts | 92 ++++ supabase/schema.sql | 115 ++++- 18 files changed, 1635 insertions(+), 337 deletions(-) create mode 100644 docs/security-audit-remediation-plan.md create mode 100644 supabase/functions/_shared/security.ts create mode 100644 supabase/functions/request-otp/index.ts create mode 100644 supabase/functions/verify-otp/index.ts diff --git a/.env.example b/.env.example index cfdada4..4623955 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key +APP_ALLOWED_ORIGINS=http://localhost:5173 +INTEGRATION_ALLOWED_ORIGINS=http://localhost:5173 +WEBHOOK_ALLOWED_ORIGINS=http://localhost:5173 +INTEGRATION_API_KEY=replace-me +INTEGRATION_WEBHOOK_SECRET=replace-me +CHATBOT_WEBHOOK_SECRET=replace-me # Self-hosted Supabase auth SUPABASE_PUBLIC_URL=https://supa.example.com diff --git a/docs/security-audit-remediation-plan.md b/docs/security-audit-remediation-plan.md new file mode 100644 index 0000000..6f08012 --- /dev/null +++ b/docs/security-audit-remediation-plan.md @@ -0,0 +1,403 @@ +# План устранения уязвимостей и защиты персональных данных + +Дата аудита: 2026-04-29 + +## 1. Контекст и поверхность атаки + +Приложение работает как веб-кабинет доставки с персональными данными клиентов: ФИО, телефон, адрес, состав заказа, история доставки, выбранные слоты, сообщения и статусы. Основные поверхности атаки: + +- `/login` — вход сотрудников по email OTP. +- `/dashboard` — авторизованные кабинеты сотрудников. +- `/delivery/:token` — публичная клиентская ссылка без логина. +- Supabase Edge Functions — публичные и интеграционные endpoints. +- Supabase Postgres — таблицы `orders`, `users`, `delivery_invitations`, `chat_messages`, `order_history`, `delivery_slots`, `integration_events`. +- PWA/localStorage — локальное хранение состояния и клиентских приглашений. + +## 2. Что уже сделано хорошо + +- Включены RLS-политики на основных таблицах: `roles`, `users`, `orders`, `order_history`, `delivery_slots`, `chat_messages`, `delivery_invitations`, `integration_events`. +- Для `orders` доступ ограничен ролью и назначением: менеджер, логист, водитель, администратор. +- Invitation token в БД хранится как `token_hash`, а не как открытый token. +- Клиентская ссылка работает без авторизованного пользователя, но доступ идёт по token. +- Email OTP настроен с `shouldCreateUser: false`, то есть саморегистрация через фронтенд не должна создавать новых пользователей. + +## 3. Критические риски + +### P0. Нет rate limits на OTP, публичные ссылки и Edge Functions + +Найдено: + +- `src/context/AuthContext.jsx:117` вызывает `supabase.auth.signInWithOtp(...)` напрямую из браузера. +- `supabase/functions/get-delivery-invitation/index.ts:57` и `supabase/functions/confirm-delivery-choice/index.ts:14` принимают публичные запросы без лимитов. +- `supabase/functions/create-delivery-invitation/index.ts:17`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook` также не имеют throttle/rate limit. + +Риск: + +- Заспамить OTP-письма на один email или набор email. +- Брутфорсить invitation token. +- Массово дергать клиентские ссылки и собирать признаки существующих заказов. +- Перегрузить Edge Functions и базу. +- Создать много `delivery_slots`, `order_history`, `integration_events` через повторные запросы. + +План устранения: + +1. Добавить таблицу `public.rate_limits`: + +```sql +create table public.rate_limits ( + id uuid primary key default gen_random_uuid(), + scope text not null, + key text not null, + window_start timestamptz not null, + count integer not null default 1, + blocked_until timestamptz, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + unique (scope, key, window_start) +); +``` + +2. Добавить SQL/RPC `public.check_rate_limit(scope, key, max_count, window_seconds, block_seconds)` с атомарным `insert ... on conflict ... update`. +3. Для Edge Functions сделать shared helper `supabase/functions/_shared/rate-limit.ts`. +4. Лимиты по умолчанию: + +| Endpoint | Ключ | Лимит | +|---|---|---| +| OTP request | `ip + email_hash` | 3 запроса / 10 минут, блок 30 минут | +| OTP verify | `ip + email_hash` | 5 попыток / 10 минут, блок 30 минут | +| get invitation | `ip + token_hash_prefix` | 30 запросов / 10 минут | +| confirm delivery | `ip + token_hash` | 5 POST / 10 минут, блок 1 час | +| create invitation | `integration_key + order_id` | 10 запросов / 10 минут | +| chatbot webhook | `provider + external_message_id/ip` | 60 запросов / минуту | +| report delivery result | `integration_key + order_id` | 10 запросов / 10 минут | + +5. Для `/login` лучше не вызывать Supabase OTP напрямую из браузера, а добавить Edge Function `request-otp`, которая валидирует email, применяет rate limit и только потом инициирует OTP. +6. Добавить тесты на rate-limit helper: allowed, exhausted, blocked, reset after window. + +### P0. Service role используется в публичных functions без отдельной авторизации + +Найдено: + +- `supabase/functions/_shared/chatbot.ts:18` создаёт Supabase client через `SUPABASE_SERVICE_ROLE_KEY`. +- `create-delivery-invitation`, `confirm-delivery-choice`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook` используют service client и обходят RLS. +- `create-delivery-invitation` принимает `orderId` из тела запроса и обновляет заказ через service role. +- `report-delivery-result` и `transfer-to-logistics` также меняют заказ по `orderId` без проверки пользователя/подписи. + +Риск: + +- Если endpoint доступен извне, злоумышленник может менять статусы заказов, создавать приглашения, переводить в платное хранение или доставлено, если узнает/подберёт `orderId`. +- RLS не помогает, потому что service role её обходит. + +План устранения: + +1. Разделить Edge Functions на публичные и интеграционные: + - публичные: `get-delivery-invitation`, `confirm-delivery-choice`; + - внутренние/integration-only: `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook`, `send-chatbot-message`. +2. Для internal functions требовать `Authorization: Bearer ` или HMAC-подпись: + - `X-Signature: hex(hmac_sha256(raw_body, secret))` + - `X-Timestamp` + - `X-Request-Id` +3. Отклонять запросы старше 5 минут. +4. Хранить обработанные `X-Request-Id` в `integration_events` или отдельной таблице `idempotency_keys`. +5. Добавить `verifyIntegrationRequest(request, rawBody)` в `_shared/auth.ts`. +6. Для functions, вызываемых из авторизованного UI, проверять JWT пользователя через anon client или `supabase.auth.getUser(jwt)`, а service role использовать только после проверки роли. + +### P0. CORS открыт на все origin + +Найдено: + +- `Access-Control-Allow-Origin: "*"` в `get-delivery-invitation`, `confirm-delivery-choice`, `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `chatbot-webhook`. + +Риск: + +- Любой сайт может дергать публичные endpoints из браузера. +- Для публичной клиентской ссылки это допустимо только частично, но для integration-only endpoints опасно. + +План устранения: + +1. Добавить shared helper `cors.ts`. +2. Настроить allowlist через env: + - `PUBLIC_APP_URL` + - `APP_ALLOWED_ORIGINS` + - `INTEGRATION_ALLOWED_ORIGINS` +3. Для public invitation endpoints разрешить только домены приложения. +4. Для integration-only endpoints либо не отвечать CORS вообще, либо разрешить только внутренний origin/n8n. +5. На неизвестный origin возвращать `403`. + +### P1. Token публичной ссылки не имеет срока жизни и политики ротации + +Найдено: + +- `delivery_invitations` не содержит `expires_at`. +- `get-delivery-invitation` возвращает данные, если token найден и состояние вычислено по заказу. +- `confirm-delivery-choice` проверяет active state, но не проверяет срок жизни token. + +Риск: + +- Старая ссылка остаётся рабочей слишком долго. +- Утечка ссылки даёт доступ к данным заказа и действию подтверждения. + +План устранения: + +1. Добавить поля: + +```sql +alter table public.delivery_invitations + add column if not exists expires_at timestamptz, + add column if not exists revoked_at timestamptz, + add column if not exists last_accessed_at timestamptz, + add column if not exists access_count integer not null default 0; +``` + +2. При создании invitation ставить `expires_at = now() + interval '7 days'` или другой срок по ТЗ. +3. В `get-delivery-invitation` и `confirm-delivery-choice` возвращать `410 Gone`, если `expires_at < now()` или `revoked_at is not null`. +4. После успешного `confirm-delivery-choice` запрещать повторное подтверждение, кроме идемпотентного возврата текущего выбора. +5. Добавить ротацию token при повторной отправке ссылки. + +### P1. Публичная ссылка раскрывает лишние персональные данные + +Найдено: + +- `get-delivery-invitation` возвращает `customerName`, `customerPhone`, `orderNumber`, `orderItems`, `orderStatus`, `deliveryAgreementStatus`. + +Риск: + +- При утечке token третье лицо видит телефон, имя, состав заказа и статус. +- Эти данные могут быть использованы для социальной инженерии. + +План устранения: + +1. Минимизировать ответ публичной страницы: + - показывать имя частично: `Мария В.`; + - телефон маскировать: `+7 *** ***-12-31`; + - номер заказа показывать частично или только последние 4 символа; + - состав заказа показывать укрупнённо, если это не нужно клиенту для выбора слота. +2. Не возвращать `orderStatus` и `deliveryAgreementStatus` в сыром виде, отдавать только public state: `awaiting_choice`, `agreed`, `delivered`, `expired`. +3. В `delivery_invitations` хранить public-safe snapshot, чтобы не тянуть весь `orders.customer`. +4. Добавить тест, что public API не отдаёт полный телефон и внутренний статус. + +### P1. Webhook не проверяет подпись провайдера и не ограничивает provider + +Найдено: + +- `chatbot-webhook` берёт `provider` из query string. +- Тело события нормализуется без проверки подписи. +- `external_message_id` есть, но повторная вставка при конфликте может давать ошибку и 500, а не идемпотентный `ok`. + +Риск: + +- Любой может отправить fake webhook и изменить заказ. +- Возможен replay одного webhook. +- Возможна подмена provider. + +План устранения: + +1. Для каждого provider добавить проверку подписи: + - Telegram: secret token/header или webhook secret path. + - VK/Max: HMAC/secret по контракту провайдера. +2. Убрать произвольный `provider` из query или валидировать строго по allowlist. +3. Для `external_message_id` реализовать `upsert`/идемпотентный возврат `ok: true` при повторе. +4. В payload логировать только безопасные поля, без полного сырого тела, если оно содержит ПДн. + +### P1. Нет ограничений на размер JSON и длину полей + +Найдено: + +- Edge Functions делают `await request.json()` без проверки `Content-Length`. +- `chatbot-webhook` записывает `text` и `payload` как пришли. +- `confirm-delivery-choice` принимает `deliveryDate` и `deliveryTime` без строгой проверки формата и allowed values. + +Риск: + +- DoS через большой body. +- Загрязнение истории/логов огромным payload. +- Некорректные даты и значения слотов. + +План устранения: + +1. Добавить helper `readJsonBody(request, { maxBytes })`. +2. Лимиты: + - public endpoints: 8 KB; + - webhook: 64 KB; + - internal dispatch: 16 KB. +3. Валидировать: + - UUID для `orderId`; + - `deliveryDate` как `YYYY-MM-DD`; + - `deliveryTime` только из `available_slots`; + - `note`, `reason`, `text` по максимальной длине. +4. Возвращать `413 Payload Too Large` и `422 Unprocessable Entity`. + +### P1. Политики RLS слишком широкие для некоторых ролей + +Найдено: + +- `users self or admin` фактически разрешает всем авторизованным пользователям читать всех пользователей. +- `orders update by workflow role` позволяет `production_lead` обновлять все заказы. +- `orders insert managers admin` всё ещё разрешает менеджеру создавать заказы, хотя по текущему ТЗ заказы приходят из 1С. + +Риск: + +- Лишний доступ к email/пользователям. +- Шире нужного доступ к заказам и ПДн. +- Внутренний пользователь может создать заказ вручную. + +План устранения: + +1. Разделить policy users: + - self может читать только себя; + - admin читает всех; + - при необходимости логист/менеджер получают только `id, name, role`, без email/last_login. +2. Запретить insert заказов из UI для manager, если 1С является единственным источником. +3. Ограничить `production_lead` только source/production полями или убрать роль из текущего демонстрационного контура. +4. Добавить database policy tests через Supabase local или SQL assertions. + +### P2. Локальный fallback содержит демонстрационные персональные данные + +Найдено: + +- `src/services/deliveryInvitationApi.js` содержит `showcase`, `client-flow-*`, имя, телефон и состав заказа. +- `AuthContext` в fallback принимает OTP `000000`. + +Риск: + +- При production build без env приложение может работать в локальном/demo режиме. +- Демонстрационные данные могут быть восприняты как реальные. + +План устранения: + +1. Добавить build guard: production build падает без `VITE_SUPABASE_URL` и `VITE_SUPABASE_ANON_KEY`. +2. Обернуть fallback только в `import.meta.env.DEV`. +3. В production не поддерживать `showcase` и `client-flow-*`. +4. Добавить тест, что в production mode fallback недоступен. + +### P2. Ошибки возвращают внутренние сообщения наружу + +Найдено: + +- Edge Functions возвращают `error instanceof Error ? error.message : "Unexpected error"` клиенту. + +Риск: + +- Утечка деталей Supabase, структуры таблиц, provider errors. + +План устранения: + +1. Ввести `publicError(status, code, message)` и `logInternalError(error, context)`. +2. Клиенту отдавать стабильные коды: `not_found`, `expired`, `rate_limited`, `invalid_payload`, `temporary_error`. +3. Внутреннюю ошибку писать в `error_logs`/observability без ПДн. + +## 4. План работ по этапам + +### Этап 1. Срочная защита от спама и неавторизованных изменений + +Срок: 1-2 дня. + +- Добавить `rate_limits` и shared rate-limit helper. +- Закрыть `create-delivery-invitation`, `transfer-to-logistics`, `report-delivery-result`, `send-chatbot-message`, `chatbot-webhook` через integration secret/HMAC. +- Ограничить CORS для Edge Functions. +- Добавить проверку размера JSON body. +- Добавить smoke-тесты на `429`, `401`, `403`. + +Критерий готовности: + +- Нельзя массово дергать OTP/public endpoints без `429`. +- Нельзя изменить заказ через integration endpoint без валидного секрета. +- Unknown origin не получает CORS-доступ. + +### Этап 2. Защита публичной клиентской ссылки + +Срок: 1-2 дня. + +- Добавить `expires_at`, `revoked_at`, `access_count`, `last_accessed_at`. +- Проверять срок действия token. +- Маскировать phone/name/orderNumber. +- Убрать внутренние `orderStatus` и `deliveryAgreementStatus` из public response. +- Проверять `deliveryDate/deliveryTime` против доступных слотов. +- Сделать подтверждение выбора идемпотентным. + +Критерий готовности: + +- Просроченная ссылка возвращает `410`. +- Public API не отдаёт полный телефон и внутренние статусы. +- Повторное подтверждение не создаёт дубли `delivery_slots`. + +### Этап 3. Ужесточение RLS и ролей + +Срок: 1 день. + +- Сузить `users` policy. +- Запретить создание заказов менеджером, если источник только 1С. +- Проверить права `production_lead` и убрать из текущего UI/политик, если роль не используется. +- Добавить SQL tests на доступ manager/logistician/driver/admin. + +Критерий готовности: + +- Сотрудник не может читать список email всех пользователей без нужной роли. +- Менеджер не создаёт заказ напрямую через Supabase client. +- Водитель видит только свои доставки. + +### Этап 4. Production hardening + +Срок: 1 день. + +- Отключить demo/local fallback в production. +- Добавить security headers: + - `Content-Security-Policy`; + - `X-Content-Type-Options: nosniff`; + - `Referrer-Policy: no-referrer`; + - `Permissions-Policy`; + - `Strict-Transport-Security` на уровне хостинга. +- Убедиться, что service worker не кеширует ответы с ПДн. +- Добавить redaction для логов: телефоны, email, адреса, token. + +Критерий готовности: + +- Production build невозможен без Supabase env. +- В кэше нет API-ответов с ПДн. +- Логи не содержат полные телефоны/email/token. + +## 5. Рекомендуемые значения лимитов + +| Сценарий | Лимит | Блокировка | +|---|---:|---:| +| Запрос OTP на email | 3 / 10 минут | 30 минут | +| Проверка OTP | 5 / 10 минут | 30 минут | +| Открытие public invitation | 30 / 10 минут | 10 минут | +| Подтверждение выбора доставки | 5 / 10 минут | 1 час | +| Создание invitation | 10 / 10 минут на orderId | 30 минут | +| Webhook сообщения | 60 / минуту на provider | 5 минут | +| Ошибки 4xx/5xx с одного IP | 100 / 10 минут | 30 минут | + +## 6. Проверки после внедрения + +- Unit tests для rate-limit helper. +- Edge Function tests: + - no secret -> `401`; + - wrong origin -> `403`; + - too many requests -> `429`; + - too large body -> `413`; + - invalid payload -> `422`; + - expired invitation -> `410`. +- RLS tests: + - manager видит только свои заказы; + - logistician видит только назначенные наборы; + - driver видит только назначенные доставки; + - обычный пользователь не читает всех `users`. +- Manual security checklist: + - OTP нельзя заспамить; + - публичная ссылка не раскрывает полный телефон; + - повторный webhook не дублирует событие; + - интеграционный endpoint без подписи не меняет заказ. + +## 7. Приоритетный backlog + +1. `P0` Rate limits для OTP и public/integration Edge Functions. +2. `P0` Integration auth/HMAC для service-role endpoints. +3. `P0` CORS allowlist вместо `*`. +4. `P1` Expiration/revocation invitation token. +5. `P1` Минимизация ПДн в public invitation response. +6. `P1` Валидация payload и body size limits. +7. `P1` Ужесточение RLS для `users` и `orders insert`. +8. `P2` Production build guard против demo fallback. +9. `P2` Security headers и service-worker cache policy. +10. `P2` Redaction логов и мониторинг подозрительной активности. diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 6f57bd0..bb270a9 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -120,10 +120,12 @@ export const AuthProvider = ({ children }) => { setAuthError(""); try { if (hasSupabaseConfig && supabase) { - const { error } = await supabase.auth.signInWithOtp(buildOtpRequestPayload(email)); + const { data, error } = await supabase.functions.invoke("request-otp", { + body: buildOtpRequestPayload(email), + }); - if (error) { - throw normalizeOtpError(error); + if (error || data?.ok === false) { + throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR)); } } else { localStorage.setItem("construction-auth-role-hint", roleHint || "manager"); @@ -144,19 +146,30 @@ export const AuthProvider = ({ children }) => { setIsLoading(true); try { if (hasSupabaseConfig && supabase) { - const { data, error } = await supabase.auth.verifyOtp({ - email, - token: otp, - type: "email", + const { data, error } = await supabase.functions.invoke("verify-otp", { + body: { email, otp }, }); - if (error) { - throw normalizeOtpError(error); + if (error || data?.ok === false) { + throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR)); } - setUser(mapSessionUserToAuthUser(data.session?.user)); + if (data?.session?.access_token && data?.session?.refresh_token) { + const { data: sessionData, error: sessionError } = await supabase.auth.setSession({ + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + }); + + if (sessionError) { + throw normalizeOtpError(sessionError); + } + + setUser(mapSessionUserToAuthUser(sessionData.session?.user || data.session.user)); + } else { + setUser(mapSessionUserToAuthUser(data?.user || null)); + } setAuthError(""); - return { success: Boolean(data.session) }; + return { success: Boolean(data?.session || data?.user) }; } if (otp !== "000000") { diff --git a/supabase/functions/README.md b/supabase/functions/README.md index f942080..171d821 100644 --- a/supabase/functions/README.md +++ b/supabase/functions/README.md @@ -5,6 +5,9 @@ Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в `chat_messages` и при необходимости обновляет статус заказа и `order_history`. +Требует подпись `X-Signature` или `Authorization: Bearer `, а также +ограничивает частоту входящих событий. + Пример вызова: ```bash @@ -32,10 +35,21 @@ curl -X POST \ - `SUPABASE_URL` - `SUPABASE_SERVICE_ROLE_KEY` +- `INTEGRATION_API_KEY` +- `INTEGRATION_WEBHOOK_SECRET` - `TELEGRAM_BOT_TOKEN` - `VK_BOT_TOKEN` - `MESSENGER_MAX_TOKEN` +## `request-otp` + +Отправляет код входа по email после проверки лимитов по IP и адресу. Используется страницей +логина вместо прямого вызова `supabase.auth.signInWithOtp` из браузера. + +## `verify-otp` + +Проверяет код входа, тоже с rate limit, и возвращает session для установки в клиенте. + ## `create-delivery-invitation` Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет diff --git a/supabase/functions/_shared/delivery-invitations.test.ts b/supabase/functions/_shared/delivery-invitations.test.ts index a258eb4..1d5b5ea 100644 --- a/supabase/functions/_shared/delivery-invitations.test.ts +++ b/supabase/functions/_shared/delivery-invitations.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_AVAILABLE_SLOTS, + buildPublicInvitationView, getClientInvitationStateFromOrderStatus, getOrderUpdateForDeliveryInvitationAction, + isInvitationExpired, normalizeAvailableSlots, } from "./delivery-invitations"; @@ -32,4 +34,51 @@ describe("delivery invitation helpers", () => { expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]); expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS); }); + + it("marks expired and revoked invitations as inactive", () => { + expect( + isInvitationExpired({ + order_id: "order-1", + token_hash: "token", + state: "awaiting_choice", + expires_at: "2026-04-01T00:00:00.000Z", + }, new Date("2026-04-02T00:00:00.000Z")), + ).toBe(true); + + expect( + isInvitationExpired({ + order_id: "order-1", + token_hash: "token", + state: "awaiting_choice", + revoked_at: "2026-04-01T00:00:00.000Z", + }), + ).toBe(true); + }); + + it("masks customer contact details in the public invitation view", () => { + const invitation = buildPublicInvitationView( + { + order_id: "order-1", + token_hash: "token", + state: "awaiting_choice", + customer_name: "Мария Волкова", + customer_phone: "+7 978 123-45-67", + order_number: "CD-240031", + available_slots: ["2026-04-15, До обеда"], + }, + { + order_number: "CD-240031", + customer: { + name: "Мария Волкова", + phone: "+7 978 123-45-67", + items: [{ name: "Кухонный гарнитур", quantity: "1 комплект" }], + }, + }, + ); + + expect(invitation.customerName).toBe("Мария В."); + expect(invitation.customerPhone).toContain("***"); + expect(invitation.orderStatus).toBeNull(); + expect(invitation.deliveryAgreementStatus).toBeNull(); + }); }); diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts index d8e4bf5..930c00b 100644 --- a/supabase/functions/_shared/delivery-invitations.ts +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -1,3 +1,8 @@ +import { + maskCustomerName, + maskPhoneNumber, +} from "./security.ts"; + export type DeliveryInvitationAction = | "create_delivery_invitation" | "send_delivery_offer" @@ -108,3 +113,68 @@ export const resolvePublicAppUrl = ( export const buildInvitationUrl = (baseUrl: string, token: string) => `${baseUrl.replace(/\/$/, "")}/delivery/${token}`; + +export type DeliveryInvitationRecord = { + id?: string; + order_id: string; + token_hash: string; + state: string; + order_number?: string | null; + customer_name?: string | null; + customer_phone?: string | null; + customer_messenger?: string | null; + available_slots?: string[] | null; + expires_at?: string | null; + revoked_at?: string | null; + delivery_date?: string | null; + delivery_time?: string | null; + sent_at?: string | null; + opened_at?: string | null; + confirmed_at?: string | null; + logistics_transferred_at?: string | null; + paid_storage_at?: string | null; + delivered_at?: string | null; + updated_at?: string | null; +}; + +export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => { + if (invitation.revoked_at) { + return true; + } + + if (!invitation.expires_at) { + return false; + } + + return new Date(invitation.expires_at).getTime() <= now.getTime(); +}; + +export const buildPublicInvitationView = ( + invitation: DeliveryInvitationRecord, + order: { + order_number?: string | null; + customer?: { name?: string | null; phone?: string | null; items?: unknown }; + status?: string | null; + delivery_agreement_status?: string | null; + }, +) => { + const availableSlots = invitation.available_slots || []; + const orderItems = Array.isArray(order.customer?.items) + ? order.customer?.items + : []; + + return { + orderId: invitation.order_id, + state: invitation.state, + token: "", + orderNumber: order.order_number || invitation.order_number || null, + customerName: maskCustomerName(order.customer?.name || invitation.customer_name || null), + customerPhone: maskPhoneNumber(order.customer?.phone || invitation.customer_phone || null), + orderItems, + availableSlots, + deliveryDate: invitation.delivery_date || null, + deliveryTime: invitation.delivery_time || null, + orderStatus: null, + deliveryAgreementStatus: null, + }; +}; diff --git a/supabase/functions/_shared/integration-events.ts b/supabase/functions/_shared/integration-events.ts index 8396882..8945f24 100644 --- a/supabase/functions/_shared/integration-events.ts +++ b/supabase/functions/_shared/integration-events.ts @@ -11,7 +11,7 @@ type IntegrationEventPayload = { export const insertIntegrationEvent = async ( supabase: { from: (table: string) => { - insert: (payload: IntegrationEventPayload) => Promise<{ error: Error | null }>; + insert: (payload: IntegrationEventPayload) => PromiseLike<{ error: Error | null }>; }; }, payload: IntegrationEventPayload, diff --git a/supabase/functions/_shared/security.ts b/supabase/functions/_shared/security.ts new file mode 100644 index 0000000..19af8f1 --- /dev/null +++ b/supabase/functions/_shared/security.ts @@ -0,0 +1,370 @@ +type CorsMode = "public" | "integration" | "webhook"; + +type JsonBodyOptions = { + maxBytes: number; + errorMessage?: string; +}; + +type RateLimitOptions = { + scope: string; + key: string; + maxCount: number; + windowSeconds: number; + blockSeconds?: number; +}; + +type RateLimitResult = { + allowed: boolean; + currentCount: number; + limitCount: number; + blockedUntil: string | null; + windowStart: string; +}; + +type IntegrationAuthOptions = { + rawBody: string; + secretEnvNames?: string[]; + tokenEnvNames?: string[]; + signatureHeader?: string; + timestampHeader?: string; + requestIdHeader?: string; + allowedClockSkewSeconds?: number; +}; + +const DEFAULT_LOCAL_ORIGINS = [ + "http://localhost:5173", + "http://localhost:4173", + "http://127.0.0.1:5173", + "http://127.0.0.1:4173", +]; + +const normalizeOrigin = (value: string) => value.replace(/\/$/, ""); + +const splitList = (value: string | null | undefined) => + (value || "") + .split(",") + .map((item) => normalizeOrigin(item.trim())) + .filter(Boolean); + +const getRequestOrigin = (request: Request) => { + const origin = request.headers.get("origin"); + if (origin) { + return normalizeOrigin(origin); + } + + const referer = request.headers.get("referer"); + if (!referer) { + return ""; + } + + try { + return normalizeOrigin(new URL(referer).origin); + } catch { + return ""; + } +}; + +const readEnv = (name: string) => { + try { + if (typeof Deno === "undefined") { + return ""; + } + return Deno.env.get(name) || ""; + } catch { + return ""; + } +}; + +const isLocalhostOrigin = (origin: string) => + /:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); + +const resolveAllowedOrigins = (mode: CorsMode) => { + const publicOrigins = [ + ...splitList(readEnv("APP_ALLOWED_ORIGINS")), + ...splitList(readEnv("PUBLIC_APP_URL")), + ...splitList(readEnv("APP_PUBLIC_URL")), + ]; + const integrationOrigins = [ + ...splitList(readEnv("INTEGRATION_ALLOWED_ORIGINS")), + ...splitList(readEnv("PUBLIC_APP_URL")), + ]; + const webhookOrigins = [ + ...splitList(readEnv("WEBHOOK_ALLOWED_ORIGINS")), + ...splitList(readEnv("PUBLIC_APP_URL")), + ]; + + const configured = + mode === "public" + ? publicOrigins + : mode === "integration" + ? integrationOrigins + : webhookOrigins; + + if (configured.length > 0) { + return Array.from(new Set(configured)); + } + + const currentMode = readEnv("NODE_ENV") || "development"; + if (currentMode === "production") { + return []; + } + + return [...DEFAULT_LOCAL_ORIGINS]; +}; + +export class HttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = "HttpError"; + } +} + +export const jsonResponse = ( + body: unknown, + status = 200, + headers: HeadersInit = {}, +) => + new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); + +export const getCorsHeaders = (request: Request, mode: CorsMode) => { + const origin = getRequestOrigin(request); + const allowedOrigins = resolveAllowedOrigins(mode); + + if (!origin) { + if (allowedOrigins.length === 0) { + return null; + } + + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + } satisfies Record; + } + + const isAllowed = + allowedOrigins.length === 0 + ? false + : allowedOrigins.some((allowedOrigin) => { + if (allowedOrigin === "*") { + return true; + } + + return origin === allowedOrigin || origin.startsWith(`${allowedOrigin}/`); + }) || (!readEnv("NODE_ENV") || readEnv("NODE_ENV") !== "production" && isLocalhostOrigin(origin)); + + if (!isAllowed) { + return null; + } + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id, x-signature, x-timestamp, x-webhook-secret", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + } satisfies Record; +}; + +export const preflightResponse = (request: Request, mode: CorsMode) => { + const corsHeaders = getCorsHeaders(request, mode); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + return new Response("ok", { + status: 204, + headers: corsHeaders, + }); +}; + +export const assertAllowedOrigin = (request: Request, mode: CorsMode) => { + const corsHeaders = getCorsHeaders(request, mode); + if (!corsHeaders) { + throw new HttpError(403, "Origin not allowed"); + } + + return corsHeaders; +}; + +export const readJsonBody = async >( + request: Request, + options: JsonBodyOptions, +): Promise<{ body: T; rawBody: string }> => { + const rawBody = await request.clone().text(); + const byteLength = new TextEncoder().encode(rawBody).length; + + if (byteLength > options.maxBytes) { + throw new HttpError(413, options.errorMessage || "Payload too large"); + } + + if (!rawBody.trim()) { + throw new HttpError(400, "Request body is required"); + } + + try { + return { + body: JSON.parse(rawBody) as T, + rawBody, + }; + } catch { + throw new HttpError(400, "Invalid JSON payload"); + } +}; + +export const getClientIp = (request: Request) => { + const forwardedFor = request.headers.get("x-forwarded-for") || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || ""; + return forwardedFor.split(",")[0]?.trim() || "unknown"; +}; + +export const sha256Hex = async (value: string) => { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +export const hashText = sha256Hex; + +const hmacHex = async (secret: string, value: string) => { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); + return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +}; + +export const verifyInternalRequest = async ( + request: Request, + rawBody: string, + options: IntegrationAuthOptions = { rawBody }, +) => { + const tokenEnvNames = options.tokenEnvNames || ["INTEGRATION_API_KEY", "INTERNAL_API_KEY"]; + const secretEnvNames = options.secretEnvNames || ["INTEGRATION_WEBHOOK_SECRET", "CHATBOT_WEBHOOK_SECRET"]; + const bearerToken = request.headers.get("authorization") || ""; + const token = bearerToken.toLowerCase().startsWith("bearer ") ? bearerToken.slice(7).trim() : ""; + const requestId = request.headers.get(options.requestIdHeader || "x-request-id") || ""; + const timestamp = request.headers.get(options.timestampHeader || "x-timestamp") || ""; + const signature = request.headers.get(options.signatureHeader || "x-signature") || ""; + const sharedTokens = tokenEnvNames.map((name) => readEnv(name)).filter(Boolean); + const sharedSecrets = secretEnvNames.map((name) => readEnv(name)).filter(Boolean); + + if (token && sharedTokens.some((candidate) => candidate === token)) { + return { requestId, authenticatedBy: "bearer" as const }; + } + + if (sharedSecrets.length === 0) { + throw new HttpError(401, "Integration auth is not configured"); + } + + if (!timestamp || !signature) { + throw new HttpError(401, "Missing integration signature"); + } + + const timestampNumber = Number(timestamp); + if (!Number.isFinite(timestampNumber)) { + throw new HttpError(401, "Invalid integration timestamp"); + } + + const now = Date.now(); + const allowedSkew = (options.allowedClockSkewSeconds || 300) * 1000; + if (Math.abs(now - timestampNumber) > allowedSkew) { + throw new HttpError(401, "Stale integration request"); + } + + const payload = `${timestamp}.${rawBody}`; + const expectedSignatures = await Promise.all( + sharedSecrets.map(async (secret) => hmacHex(secret, payload)), + ); + + if (!expectedSignatures.some((candidate) => candidate === signature)) { + throw new HttpError(401, "Invalid integration signature"); + } + + return { requestId, authenticatedBy: "hmac" as const }; +}; + +export const maskPhoneNumber = (phone: string | null | undefined) => { + const value = String(phone || "").trim(); + if (!value) { + return null; + } + + const digits = value.replace(/\D/g, ""); + if (digits.length < 4) { + return value; + } + + const tail = digits.slice(-4); + const country = digits.startsWith("7") || digits.startsWith("8") ? "+7" : "+"; + return `${country} *** ***-${tail.slice(0, 2)}-${tail.slice(2)}`; +}; + +export const maskCustomerName = (name: string | null | undefined) => { + const value = String(name || "").trim(); + if (!value) { + return null; + } + + const parts = value.split(/\s+/).filter(Boolean); + if (parts.length === 1) { + return `${parts[0].slice(0, 1)}.`; + } + + return `${parts[0]} ${parts[1].slice(0, 1)}.`; +}; + +export const maskOrderNumber = (orderNumber: string | null | undefined) => { + const value = String(orderNumber || "").trim(); + if (!value) { + return null; + } + + if (value.length <= 4) { + return value; + } + + return `…${value.slice(-4)}`; +}; + +export const requireRateLimit = async ( + supabase: { + rpc: ( + name: string, + params: Record, + ) => PromiseLike<{ data: RateLimitResult | null; error: Error | null }>; + }, + options: RateLimitOptions, +) => { + const { data, error } = await supabase.rpc("check_rate_limit", { + p_scope: options.scope, + p_key: options.key, + p_max_count: options.maxCount, + p_window_seconds: options.windowSeconds, + p_block_seconds: options.blockSeconds || 0, + }); + + if (error) { + throw error; + } + + if (!data?.allowed) { + throw new HttpError(429, "Too many requests"); + } + + return data; +}; diff --git a/supabase/functions/chatbot-webhook/index.ts b/supabase/functions/chatbot-webhook/index.ts index 729e28a..3acd703 100644 --- a/supabase/functions/chatbot-webhook/index.ts +++ b/supabase/functions/chatbot-webhook/index.ts @@ -6,31 +6,69 @@ import { orderUpdateByAction, type ProviderName, } from "../_shared/chatbot.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", -}; +const MAX_BODY_BYTES = 64 * 1024; + +const allowedProviders = new Set(["telegram", "vk", "messenger_max"]); Deno.serve(async (request) => { if (request.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + const corsHeaders = getCorsHeaders(request, "webhook"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403); } + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "webhook") || {}; + try { const url = new URL(request.url); const provider = url.searchParams.get("provider") as ProviderName | null; - if (!provider) { + if (!provider || !allowedProviders.has(provider)) { return json({ error: "provider is required" }, 400); } - const body = (await request.json()) as Record; + const { body, rawBody } = await readJsonBody>(request, { + maxBytes: MAX_BODY_BYTES, + }); + await verifyInternalRequest(request, rawBody, { + rawBody, + secretEnvNames: [ + `CHATBOT_WEBHOOK_SECRET_${provider.toUpperCase()}`, + "CHATBOT_WEBHOOK_SECRET", + ], + tokenEnvNames: [ + `CHATBOT_WEBHOOK_TOKEN_${provider.toUpperCase()}`, + "CHATBOT_WEBHOOK_TOKEN", + ], + }); + const event = normalizeIncomingEvent(provider, body); if (!event.orderId) { return json({ error: "order_id is required" }, 400); } const supabase = createServiceClient(); + const rateKey = event.externalMessageId || (await hashText(`${provider}:${getClientIp(request)}:${event.text}`)); + + await requireRateLimit(supabase, { + scope: `webhook-${provider}`, + key: rateKey, + maxCount: 60, + windowSeconds: 60, + blockSeconds: 300, + }); + const orderUpdate = orderUpdateByAction(event.action); const messagePayload = { @@ -44,7 +82,7 @@ Deno.serve(async (request) => { }; const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload); - if (messageError) { + if (messageError && messageError.code !== "23505") { throw messageError; } @@ -89,24 +127,15 @@ Deno.serve(async (request) => { } return new Response(JSON.stringify({ ok: true }), { - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, + headers: corsHeaders, }); } catch (error) { - return new Response( - JSON.stringify({ + return json( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error", - }), - { - status: 500, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 500, ); } }); diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 1c4d58c..54c3ac7 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -2,49 +2,89 @@ import { getOrderUpdateForDeliveryInvitationAction, hashInvitationToken, isActiveInvitationState, + isInvitationExpired, } from "../_shared/delivery-invitations.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +const MAX_BODY_BYTES = 8 * 1024; + +type ConfirmBody = { + token?: string; + deliveryDate?: string; + deliveryTime?: string; +}; + +const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + +const resolveRequestedSlot = ( + invitation: { + delivery_date?: string | null; + delivery_time?: string | null; + available_slots?: string[] | null; + }, + body: ConfirmBody, +) => { + const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); + const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim(); + + if (!deliveryDate || !deliveryTime || !isValidDate(deliveryDate)) { + return null; + } + + const slotLabel = `${deliveryDate}, ${deliveryTime}`; + const availableSlots = invitation.available_slots || []; + + if (availableSlots.length > 0 && !availableSlots.includes(slotLabel)) { + return null; + } + + return { deliveryDate, deliveryTime }; }; Deno.serve(async (request) => { if (request.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + return preflightResponse(request, "public"); } if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } try { - const body = (await request.json()) as { - token?: string; - deliveryDate?: string; - deliveryTime?: string; - }; + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); if (!body.token) { - return new Response(JSON.stringify({ error: "token is required" }), { - status: 400, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders); } const tokenHash = await hashInvitationToken(body.token); const supabase = createServiceClient(); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "invitation-confirm", + key: `${ipHash}:${tokenHash.slice(0, 16)}`, + maxCount: 5, + windowSeconds: 600, + blockSeconds: 3600, + }); const { data: invitation, error: invitationError } = await supabase .from("delivery_invitations") @@ -54,21 +94,16 @@ Deno.serve(async (request) => { if (invitationError) { if (invitationError.code === "PGRST116") { - return new Response( - JSON.stringify({ ok: false, error: "Invitation not found" }), - { - status: 404, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }, - ); + return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders); } throw invitationError; } + if (isInvitationExpired(invitation)) { + return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); + } + const { data: currentOrder, error: orderError } = await supabase .from("orders") .select("id, status, delivery_agreement_status") @@ -80,32 +115,39 @@ Deno.serve(async (request) => { } if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) { - return new Response( - JSON.stringify({ + return jsonResponse( + { ok: false, error: "Invitation is no longer active", - }), - { - status: 409, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 409, + corsHeaders, + ); + } + + const requestedSlot = resolveRequestedSlot(invitation, body); + if (!requestedSlot) { + return jsonResponse( + { + ok: false, + error: "Selected slot is not available", + }, + 422, + corsHeaders, ); } const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice"); - const deliveryDate = body.deliveryDate || new Date().toISOString().slice(0, 10); - const deliveryTime = body.deliveryTime || "Первая половина дня"; const { error: invitationUpdateError } = await supabase .from("delivery_invitations") .update({ state: "agreed", - delivery_date: deliveryDate, - delivery_time: deliveryTime, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, confirmed_at: new Date().toISOString(), + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), }) .eq("id", invitation.id); @@ -127,8 +169,8 @@ Deno.serve(async (request) => { const { error: slotError } = await supabase.from("delivery_slots").insert({ order_id: invitation.order_id, - delivery_date: deliveryDate, - delivery_time: deliveryTime, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, logistician_id: null, status: "confirmed_by_client", }); @@ -145,8 +187,8 @@ Deno.serve(async (request) => { metadata: { old_delivery_agreement_status: currentOrder.delivery_agreement_status, new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, - delivery_date: deliveryDate, - delivery_time: deliveryTime, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, }, }); @@ -160,38 +202,34 @@ Deno.serve(async (request) => { direction: "inbound", status: "success", payload: { - delivery_date: deliveryDate, - delivery_time: deliveryTime, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, }, }); - return new Response( - JSON.stringify({ + return jsonResponse( + { ok: true, orderId: invitation.order_id, status: orderUpdate?.status, deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus, - }), - { - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 200, + corsHeaders, ); } catch (error) { - return new Response( - JSON.stringify({ + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error", - }), - { - status: 500, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 500, + corsHeaders, ); } }); diff --git a/supabase/functions/create-delivery-invitation/index.ts b/supabase/functions/create-delivery-invitation/index.ts index f466a92..23ed618 100644 --- a/supabase/functions/create-delivery-invitation/index.ts +++ b/supabase/functions/create-delivery-invitation/index.ts @@ -8,49 +8,68 @@ import { } from "../_shared/delivery-invitations.ts"; import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getClientIp, + getCorsHeaders, + jsonResponse, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +const MAX_BODY_BYTES = 16 * 1024; + +type CreateInvitationBody = { + orderId?: string; + orderNumber?: string; + customerName?: string; + customerPhone?: string; + customerMessenger?: string; + availableSlots?: string[]; + source?: string; }; Deno.serve(async (request) => { if (request.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ error: "Method not allowed" }, 405); } + const corsHeaders = getCorsHeaders(request, "integration") || {}; + try { - const body = (await request.json()) as { - orderId?: string; - orderNumber?: string; - customerName?: string; - customerPhone?: string; - customerMessenger?: string; - availableSlots?: string[]; - }; + const { body, rawBody } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + const auth = await verifyInternalRequest(request, rawBody, { + rawBody, + allowedClockSkewSeconds: 300, + }); if (!body.orderId) { - return json({ error: "orderId is required" }, 400); + return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); } + const supabase = createServiceClient(); + await requireRateLimit(supabase, { + scope: "delivery-invitation-create", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + const token = generateInvitationToken(); const tokenHash = await hashInvitationToken(token); - const supabase = createServiceClient(); const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation"); const { data: currentOrder, error: orderError } = await supabase .from("orders") - .select("id, status, delivery_agreement_status, delivery_flow_started_at") + .select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at") .eq("id", body.orderId) .single(); @@ -60,7 +79,9 @@ Deno.serve(async (request) => { const { data: existingInvitation, error: existingInvitationError } = await supabase .from("delivery_invitations") - .select("id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at") + .select( + "id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at", + ) .eq("order_id", body.orderId) .maybeSingle(); @@ -69,8 +90,8 @@ Deno.serve(async (request) => { } if (currentOrder.delivery_flow_started_at || existingInvitation) { - return new Response( - JSON.stringify({ + return jsonResponse( + { ok: true, alreadyStarted: true, invitation: existingInvitation @@ -87,13 +108,9 @@ Deno.serve(async (request) => { orderId: body.orderId, state: "awaiting_choice", }, - }), - { - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 200, + corsHeaders, ); } @@ -106,6 +123,7 @@ Deno.serve(async (request) => { customer_phone: body.customerPhone || null, customer_messenger: body.customerMessenger || null, available_slots: normalizeAvailableSlots(body.availableSlots), + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), sent_at: new Date().toISOString(), }; @@ -139,6 +157,7 @@ Deno.serve(async (request) => { old_delivery_agreement_status: currentOrder.delivery_agreement_status, new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, channel: channelFromProvider("telegram"), + auth: auth.authenticatedBy, }, }); @@ -159,8 +178,8 @@ Deno.serve(async (request) => { const publicBaseUrl = resolvePublicAppUrl(request); - return new Response( - JSON.stringify({ + return jsonResponse( + { ok: true, invitation: { orderId: body.orderId, @@ -169,27 +188,23 @@ Deno.serve(async (request) => { state: "awaiting_choice", availableSlots: invitationPayload.available_slots, }, - }), - { - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 200, + corsHeaders, ); } catch (error) { - return new Response( - JSON.stringify({ + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error", - }), - { - status: 500, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 500, + corsHeaders, ); } }); diff --git a/supabase/functions/get-delivery-invitation/index.ts b/supabase/functions/get-delivery-invitation/index.ts index 8474949..effefd9 100644 --- a/supabase/functions/get-delivery-invitation/index.ts +++ b/supabase/functions/get-delivery-invitation/index.ts @@ -1,91 +1,68 @@ import { + buildPublicInvitationView, getClientInvitationStateFromOrderStatus, hashInvitationToken, isActiveInvitationState, + isInvitationExpired, } from "../_shared/delivery-invitations.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; -const normalizeOrderItems = ( - items: unknown, -): Array<{ name: string; quantity?: string }> => { - if (!Array.isArray(items)) { - return []; - } +const MAX_BODY_BYTES = 8 * 1024; - return items - .map((item) => { - if (typeof item === "string") { - const [namePart, quantityPart] = item.split("|").map((part) => part.trim()); - const name = namePart || item.trim(); - if (!name) { - return null; - } - - return quantityPart ? { name, quantity: quantityPart } : { name }; - } - - if (item && typeof item === "object") { - const typedItem = item as { name?: unknown; quantity?: unknown; label?: unknown }; - const name = typeof typedItem.name === "string" - ? typedItem.name.trim() - : typeof typedItem.label === "string" - ? typedItem.label.trim() - : ""; - const quantity = typeof typedItem.quantity === "string" - ? typedItem.quantity.trim() - : typeof typedItem.quantity === "number" - ? String(typedItem.quantity) - : ""; - - if (!name && !quantity) { - return null; - } - - return quantity ? { name: name || "Позиция", quantity } : { name: name || "Позиция" }; - } - - return null; - }) - .filter((item): item is { name: string; quantity?: string } => Boolean(item)); +type InvitationBody = { + token?: string; }; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +const getTokenFromRequest = async (request: Request) => { + if (request.method === "GET") { + return new URL(request.url).searchParams.get("token") || ""; + } + + const { body } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + return String(body.token || "").trim(); }; Deno.serve(async (request) => { if (request.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + return preflightResponse(request, "public"); } if (!["GET", "POST"].includes(request.method)) { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); } try { - const url = new URL(request.url); - const token = request.method === "POST" - ? ((await request.json()) as { token?: string })?.token || "" - : url.searchParams.get("token") || ""; + const token = await getTokenFromRequest(request); if (!token) { - return new Response(JSON.stringify({ error: "token is required" }), { - status: 400, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders); } const tokenHash = await hashInvitationToken(token); const supabase = createServiceClient(); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "invitation-get", + key: `${ipHash}:${tokenHash.slice(0, 16)}`, + maxCount: 30, + windowSeconds: 600, + }); const { data: invitation, error: invitationError } = await supabase .from("delivery_invitations") @@ -95,21 +72,16 @@ Deno.serve(async (request) => { if (invitationError) { if (invitationError.code === "PGRST116") { - return new Response( - JSON.stringify({ ok: false, error: "Invitation not found" }), - { - status: 404, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }, - ); + return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders); } throw invitationError; } + if (isInvitationExpired(invitation)) { + return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); + } + const { data: order, error: orderError } = await supabase .from("orders") .select("id, order_number, status, delivery_agreement_status, customer") @@ -127,48 +99,47 @@ Deno.serve(async (request) => { .from("delivery_invitations") .update({ opened_at: new Date().toISOString(), + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), + }) + .eq("id", invitation.id); + } else { + await supabase + .from("delivery_invitations") + .update({ + access_count: (invitation.access_count || 0) + 1, + last_accessed_at: new Date().toISOString(), }) .eq("id", invitation.id); } - return new Response( - JSON.stringify({ + const invitationView = buildPublicInvitationView(invitation, order); + + return jsonResponse( + { ok: true, invitation: { - orderId: invitation.order_id, + ...invitationView, + token, state: publicState, - token: token, - orderNumber: order.order_number, - customerName: order.customer?.name || invitation.customer_name || null, - customerPhone: order.customer?.phone || invitation.customer_phone || null, - orderItems: normalizeOrderItems(order.customer?.items), - availableSlots: invitation.available_slots || [], - deliveryDate: invitation.delivery_date || null, - deliveryTime: invitation.delivery_time || null, - orderStatus: order.status, - deliveryAgreementStatus: order.delivery_agreement_status, - }, - }), - { - headers: { - ...corsHeaders, - "Content-Type": "application/json", }, }, + 200, + corsHeaders, ); } catch (error) { - return new Response( - JSON.stringify({ + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error", - }), - { - status: 500, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 500, + corsHeaders, ); } }); diff --git a/supabase/functions/report-delivery-result/index.ts b/supabase/functions/report-delivery-result/index.ts index 96168d9..b014404 100644 --- a/supabase/functions/report-delivery-result/index.ts +++ b/supabase/functions/report-delivery-result/index.ts @@ -3,46 +3,54 @@ import { } from "../_shared/delivery-invitations.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getCorsHeaders, + jsonResponse, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +const MAX_BODY_BYTES = 16 * 1024; + +type ReportBody = { + orderId?: string; + result?: "delivered" | "problem"; + note?: string; + payload?: Record; }; Deno.serve(async (request) => { if (request.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403); } if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ error: "Method not allowed" }, 405); } + const corsHeaders = getCorsHeaders(request, "integration") || {}; + try { - const body = (await request.json()) as { - orderId?: string; - result?: "delivered" | "problem"; - note?: string; - payload?: Record; - }; + const { body, rawBody } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + await verifyInternalRequest(request, rawBody, { rawBody }); if (!body.orderId) { - return new Response(JSON.stringify({ error: "orderId is required" }), { - status: 400, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); } const supabase = createServiceClient(); + await requireRateLimit(supabase, { + scope: "delivery-report", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + const { data: currentOrder, error: orderError } = await supabase .from("orders") .select("id, status, delivery_agreement_status") @@ -114,8 +122,8 @@ Deno.serve(async (request) => { }, }); - return new Response( - JSON.stringify({ + return jsonResponse( + { ok: true, orderId: body.orderId, status: nextStatus, @@ -123,27 +131,23 @@ Deno.serve(async (request) => { ? "Подтверждено клиентом" : body.note || currentOrder.delivery_agreement_status || "Ошибка отправки", workflowStatus: nextStatus, - }), - { - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 200, + corsHeaders, ); } catch (error) { - return new Response( - JSON.stringify({ + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error", - }), - { - status: 500, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 500, + corsHeaders, ); } }); diff --git a/supabase/functions/request-otp/index.ts b/supabase/functions/request-otp/index.ts new file mode 100644 index 0000000..57a793c --- /dev/null +++ b/supabase/functions/request-otp/index.ts @@ -0,0 +1,80 @@ +import { createServiceClient } from "../_shared/chatbot.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody<{ email?: string }>(request, { + maxBytes: MAX_BODY_BYTES, + }); + const email = String(body.email || "").trim().toLowerCase(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); + } + + const supabase = createServiceClient(); + const emailHash = await hashText(email); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "otp-request", + key: `${ipHash}:${emailHash}`, + maxCount: 3, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { + shouldCreateUser: false, + }, + }); + + if (error) { + return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders); + } + + return jsonResponse({ ok: true }, 200, corsHeaders); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/supabase/functions/send-chatbot-message/index.ts b/supabase/functions/send-chatbot-message/index.ts index bc3e6c0..e7d3dc8 100644 --- a/supabase/functions/send-chatbot-message/index.ts +++ b/supabase/functions/send-chatbot-message/index.ts @@ -5,6 +5,12 @@ import { type ProviderName, } from "../_shared/chatbot.ts"; import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts"; +import { + getCorsHeaders, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; const providerTokens: Record = { telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"), @@ -12,6 +18,8 @@ const providerTokens: Record = { messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"), }; +const MAX_BODY_BYTES = 16 * 1024; + const sendToProvider = async ({ provider, recipientId, @@ -38,22 +46,41 @@ const sendToProvider = async ({ }; Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : json({ error: "Origin not allowed" }, 403); + } + if (request.method !== "POST") { return json({ error: "Method not allowed" }, 405); } + const corsHeaders = getCorsHeaders(request, "integration") || {}; + try { - const body = (await request.json()) as { + const { body, rawBody } = await readJsonBody<{ provider: ProviderName; orderId: string; recipientId: string; text: string; buttons?: Array<{ title: string; action: string }>; workflowAction?: OutboundWorkflowAction; - }; + }>(request, { + maxBytes: MAX_BODY_BYTES, + }); + + await verifyInternalRequest(request, rawBody, { rawBody }); + + const supabase = createServiceClient(); + await requireRateLimit(supabase, { + scope: "chatbot-dispatch", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); const dispatchResult = await sendToProvider(body); - const supabase = createServiceClient(); const { error } = await supabase.from("chat_messages").insert({ order_id: body.orderId, diff --git a/supabase/functions/transfer-to-logistics/index.ts b/supabase/functions/transfer-to-logistics/index.ts index 911594b..e29482a 100644 --- a/supabase/functions/transfer-to-logistics/index.ts +++ b/supabase/functions/transfer-to-logistics/index.ts @@ -3,46 +3,54 @@ import { } from "../_shared/delivery-invitations.ts"; import { createServiceClient } from "../_shared/chatbot.ts"; import { insertIntegrationEvent } from "../_shared/integration-events.ts"; +import { + getCorsHeaders, + jsonResponse, + readJsonBody, + requireRateLimit, + verifyInternalRequest, +} from "../_shared/security.ts"; -const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +const MAX_BODY_BYTES = 16 * 1024; + +type TransferBody = { + orderId?: string; + reason?: string; + note?: string; + targetStatus?: "Передан логисту" | "Платное хранение"; }; Deno.serve(async (request) => { if (request.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + const corsHeaders = getCorsHeaders(request, "integration"); + return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403); } if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { - status: 405, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ error: "Method not allowed" }, 405); } + const corsHeaders = getCorsHeaders(request, "integration") || {}; + try { - const body = (await request.json()) as { - orderId?: string; - reason?: string; - note?: string; - targetStatus?: "Передан логисту" | "Платное хранение"; - }; + const { body, rawBody } = await readJsonBody(request, { + maxBytes: MAX_BODY_BYTES, + }); + await verifyInternalRequest(request, rawBody, { rawBody }); if (!body.orderId) { - return new Response(JSON.stringify({ error: "orderId is required" }), { - status: 400, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - }); + return jsonResponse({ error: "orderId is required" }, 400, corsHeaders); } const supabase = createServiceClient(); + await requireRateLimit(supabase, { + scope: "delivery-transfer", + key: body.orderId, + maxCount: 10, + windowSeconds: 600, + blockSeconds: 1800, + }); + const { data: currentOrder, error: orderError } = await supabase .from("orders") .select("id, status, delivery_agreement_status") @@ -113,33 +121,29 @@ Deno.serve(async (request) => { }, }); - return new Response( - JSON.stringify({ + return jsonResponse( + { ok: true, orderId: body.orderId, status: orderUpdate?.status, deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus, - }), - { - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 200, + corsHeaders, ); } catch (error) { - return new Response( - JSON.stringify({ + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error", - }), - { - status: 500, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, }, + 500, + corsHeaders, ); } }); diff --git a/supabase/functions/verify-otp/index.ts b/supabase/functions/verify-otp/index.ts new file mode 100644 index 0000000..65ac06e --- /dev/null +++ b/supabase/functions/verify-otp/index.ts @@ -0,0 +1,92 @@ +import { createServiceClient } from "../_shared/chatbot.ts"; +import { + getClientIp, + getCorsHeaders, + hashText, + jsonResponse, + preflightResponse, + readJsonBody, + requireRateLimit, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "public"); + } + + if (request.method !== "POST") { + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); + } + + const corsHeaders = getCorsHeaders(request, "public"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + const { body } = await readJsonBody<{ email?: string; otp?: string }>(request, { + maxBytes: MAX_BODY_BYTES, + }); + const email = String(body.email || "").trim().toLowerCase(); + const otp = String(body.otp || "").trim(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Valid email is required" }, 400, corsHeaders); + } + + if (!otp || otp.length < 4 || otp.length > 12) { + return jsonResponse({ ok: false, error: "Valid OTP is required" }, 400, corsHeaders); + } + + const supabase = createServiceClient(); + const emailHash = await hashText(email); + const ipHash = await hashText(getClientIp(request)); + + await requireRateLimit(supabase, { + scope: "otp-verify", + key: `${ipHash}:${emailHash}`, + maxCount: 5, + windowSeconds: 600, + blockSeconds: 1800, + }); + + const { data, error } = await supabase.auth.verifyOtp({ + email, + token: otp, + type: "email", + }); + + if (error) { + return jsonResponse({ ok: false, error: error.message }, 400, corsHeaders); + } + + return jsonResponse( + { + ok: true, + session: data.session || null, + user: data.session?.user || null, + }, + 200, + corsHeaders, + ); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + + return jsonResponse( + { + ok: false, + error: error instanceof Error ? error.message : "Unexpected error", + }, + 500, + corsHeaders, + ); + } +}); diff --git a/supabase/schema.sql b/supabase/schema.sql index e8fd501..6fea8af 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -95,6 +95,10 @@ create table if not exists public.delivery_invitations ( customer_phone text, customer_messenger text, available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'], + expires_at timestamptz, + revoked_at timestamptz, + access_count integer not null default 0, + last_accessed_at timestamptz, delivery_date date, delivery_time text, sent_at timestamptz, @@ -119,6 +123,18 @@ create table if not exists public.integration_events ( created_at timestamptz not null default timezone('utc', now()) ); +create table if not exists public.rate_limits ( + id uuid primary key default gen_random_uuid(), + scope text not null, + rate_key text not null, + window_start timestamptz not null, + count integer not null default 1, + blocked_until timestamptz, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + unique (scope, rate_key, window_start) +); + alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато'; alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id); alter table public.orders add column if not exists ready_for_delivery_at timestamptz; @@ -130,6 +146,10 @@ alter table public.chat_messages check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email')); alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice'; +alter table public.delivery_invitations add column if not exists expires_at timestamptz; +alter table public.delivery_invitations add column if not exists revoked_at timestamptz; +alter table public.delivery_invitations add column if not exists access_count integer not null default 0; +alter table public.delivery_invitations add column if not exists last_accessed_at timestamptz; alter table public.delivery_invitations add column if not exists delivery_date date; alter table public.delivery_invitations add column if not exists delivery_time text; alter table public.delivery_invitations add column if not exists sent_at timestamptz; @@ -172,6 +192,13 @@ alter table public.integration_events add column if not exists source text not n alter table public.integration_events add column if not exists status text not null default 'success'; alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb; alter table public.integration_events add column if not exists error_message text; +alter table public.rate_limits add column if not exists scope text not null; +alter table public.rate_limits add column if not exists rate_key text not null; +alter table public.rate_limits add column if not exists window_start timestamptz not null; +alter table public.rate_limits add column if not exists count integer not null default 1; +alter table public.rate_limits add column if not exists blocked_until timestamptz; +alter table public.rate_limits add column if not exists created_at timestamptz not null default timezone('utc', now()); +alter table public.rate_limits add column if not exists updated_at timestamptz not null default timezone('utc', now()); create index if not exists idx_orders_delivery_set_key on public.orders (delivery_set_key); create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status); @@ -348,8 +375,87 @@ create index if not exists idx_chat_messages_search on public.chat_messages usin create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id); create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash); create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state); +create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at); create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc); create index if not exists idx_integration_events_event_type on public.integration_events (event_type); +create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc); +create index if not exists idx_rate_limits_blocked_until on public.rate_limits (blocked_until); + +create or replace function public.check_rate_limit( + p_scope text, + p_key text, + p_max_count integer, + p_window_seconds integer, + p_block_seconds integer default 0 +) +returns table ( + allowed boolean, + current_count integer, + limit_count integer, + blocked_until timestamptz, + window_start timestamptz +) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_now timestamptz := timezone('utc', now()); + v_window_start timestamptz; + v_count integer; + v_blocked_until timestamptz; +begin + if p_max_count <= 0 then + raise exception 'max_count must be positive'; + end if; + + if p_window_seconds <= 0 then + raise exception 'window_seconds must be positive'; + end if; + + v_window_start := to_timestamp(floor(extract(epoch from v_now) / p_window_seconds) * p_window_seconds); + + select rl.blocked_until + into v_blocked_until + from public.rate_limits rl + where rl.scope = p_scope + and rl.rate_key = p_key + and rl.blocked_until is not null + and rl.blocked_until > v_now + order by rl.blocked_until desc + limit 1; + + if v_blocked_until is not null then + return query + select false, 0, p_max_count, v_blocked_until, v_window_start; + return; + end if; + + insert into public.rate_limits (scope, rate_key, window_start, count, blocked_until) + values (p_scope, p_key, v_window_start, 1, null) + on conflict (scope, rate_key, window_start) + do update set + count = public.rate_limits.count + 1, + blocked_until = case + when public.rate_limits.count + 1 > p_max_count and p_block_seconds > 0 then greatest( + coalesce(public.rate_limits.blocked_until, v_now), + v_now + make_interval(secs => p_block_seconds) + ) + else public.rate_limits.blocked_until + end, + updated_at = v_now + returning count, blocked_until + into v_count, v_blocked_until; + + return query + select + v_count <= p_max_count and (v_blocked_until is null or v_blocked_until <= v_now), + v_count, + p_max_count, + v_blocked_until, + v_window_start; +end; +$$; alter table public.roles enable row level security; alter table public.users enable row level security; @@ -386,7 +492,7 @@ using (public.current_role_name() = 'admin'); drop policy if exists "users self or admin" on public.users; create policy "users self or admin" on public.users for select -using (public.current_role_name() is not null); +using (public.current_role_name() = 'admin' or id = auth.uid()); drop policy if exists "users admin update" on public.users; create policy "users admin update" on public.users @@ -571,6 +677,13 @@ for all using (public.current_role_name() = 'admin') with check (public.current_role_name() = 'admin'); +alter table public.rate_limits enable row level security; +drop policy if exists "rate limits admin only" on public.rate_limits; +create policy "rate limits admin only" on public.rate_limits +for all +using (public.current_role_name() = 'admin') +with check (public.current_role_name() = 'admin'); + drop policy if exists "integration events admin only" on public.integration_events; create policy "integration events admin only" on public.integration_events for all