Harden auth and delivery endpoints
This commit is contained in:
parent
5dcfa80940
commit
e29a51e7ea
|
|
@ -1,5 +1,11 @@
|
||||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
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
|
# Self-hosted Supabase auth
|
||||||
SUPABASE_PUBLIC_URL=https://supa.example.com
|
SUPABASE_PUBLIC_URL=https://supa.example.com
|
||||||
|
|
|
||||||
|
|
@ -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 <INTEGRATION_API_KEY>` или 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 логов и мониторинг подозрительной активности.
|
||||||
|
|
@ -120,10 +120,12 @@ export const AuthProvider = ({ children }) => {
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
try {
|
try {
|
||||||
if (hasSupabaseConfig && supabase) {
|
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) {
|
if (error || data?.ok === false) {
|
||||||
throw normalizeOtpError(error);
|
throw normalizeOtpError(error || new Error(data?.error || PROFILE_LOAD_ERROR));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
|
localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
|
||||||
|
|
@ -144,19 +146,30 @@ export const AuthProvider = ({ children }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
if (hasSupabaseConfig && supabase) {
|
if (hasSupabaseConfig && supabase) {
|
||||||
const { data, error } = await supabase.auth.verifyOtp({
|
const { data, error } = await supabase.functions.invoke("verify-otp", {
|
||||||
email,
|
body: { email, otp },
|
||||||
token: otp,
|
|
||||||
type: "email",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error || data?.ok === false) {
|
||||||
throw normalizeOtpError(error);
|
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("");
|
setAuthError("");
|
||||||
return { success: Boolean(data.session) };
|
return { success: Boolean(data?.session || data?.user) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otp !== "000000") {
|
if (otp !== "000000") {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
|
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
|
||||||
`chat_messages` и при необходимости обновляет статус заказа и `order_history`.
|
`chat_messages` и при необходимости обновляет статус заказа и `order_history`.
|
||||||
|
|
||||||
|
Требует подпись `X-Signature` или `Authorization: Bearer <INTEGRATION_API_KEY>`, а также
|
||||||
|
ограничивает частоту входящих событий.
|
||||||
|
|
||||||
Пример вызова:
|
Пример вызова:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -32,10 +35,21 @@ curl -X POST \
|
||||||
|
|
||||||
- `SUPABASE_URL`
|
- `SUPABASE_URL`
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
|
- `INTEGRATION_API_KEY`
|
||||||
|
- `INTEGRATION_WEBHOOK_SECRET`
|
||||||
- `TELEGRAM_BOT_TOKEN`
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
- `VK_BOT_TOKEN`
|
- `VK_BOT_TOKEN`
|
||||||
- `MESSENGER_MAX_TOKEN`
|
- `MESSENGER_MAX_TOKEN`
|
||||||
|
|
||||||
|
## `request-otp`
|
||||||
|
|
||||||
|
Отправляет код входа по email после проверки лимитов по IP и адресу. Используется страницей
|
||||||
|
логина вместо прямого вызова `supabase.auth.signInWithOtp` из браузера.
|
||||||
|
|
||||||
|
## `verify-otp`
|
||||||
|
|
||||||
|
Проверяет код входа, тоже с rate limit, и возвращает session для установки в клиенте.
|
||||||
|
|
||||||
## `create-delivery-invitation`
|
## `create-delivery-invitation`
|
||||||
|
|
||||||
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AVAILABLE_SLOTS,
|
DEFAULT_AVAILABLE_SLOTS,
|
||||||
|
buildPublicInvitationView,
|
||||||
getClientInvitationStateFromOrderStatus,
|
getClientInvitationStateFromOrderStatus,
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
getOrderUpdateForDeliveryInvitationAction,
|
||||||
|
isInvitationExpired,
|
||||||
normalizeAvailableSlots,
|
normalizeAvailableSlots,
|
||||||
} from "./delivery-invitations";
|
} from "./delivery-invitations";
|
||||||
|
|
||||||
|
|
@ -32,4 +34,51 @@ describe("delivery invitation helpers", () => {
|
||||||
expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]);
|
expect(normalizeAvailableSlots([" Утро ", "", "Вечер", "Утро"])).toEqual(["Утро", "Вечер"]);
|
||||||
expect(normalizeAvailableSlots([])).toEqual(DEFAULT_AVAILABLE_SLOTS);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
import {
|
||||||
|
maskCustomerName,
|
||||||
|
maskPhoneNumber,
|
||||||
|
} from "./security.ts";
|
||||||
|
|
||||||
export type DeliveryInvitationAction =
|
export type DeliveryInvitationAction =
|
||||||
| "create_delivery_invitation"
|
| "create_delivery_invitation"
|
||||||
| "send_delivery_offer"
|
| "send_delivery_offer"
|
||||||
|
|
@ -108,3 +113,68 @@ export const resolvePublicAppUrl = (
|
||||||
|
|
||||||
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
||||||
`${baseUrl.replace(/\/$/, "")}/delivery/${token}`;
|
`${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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ type IntegrationEventPayload = {
|
||||||
export const insertIntegrationEvent = async (
|
export const insertIntegrationEvent = async (
|
||||||
supabase: {
|
supabase: {
|
||||||
from: (table: string) => {
|
from: (table: string) => {
|
||||||
insert: (payload: IntegrationEventPayload) => Promise<{ error: Error | null }>;
|
insert: (payload: IntegrationEventPayload) => PromiseLike<{ error: Error | null }>;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
payload: IntegrationEventPayload,
|
payload: IntegrationEventPayload,
|
||||||
|
|
|
||||||
|
|
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <T extends Record<string, unknown>>(
|
||||||
|
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<string, unknown>,
|
||||||
|
) => 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;
|
||||||
|
};
|
||||||
|
|
@ -6,31 +6,69 @@ import {
|
||||||
orderUpdateByAction,
|
orderUpdateByAction,
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "../_shared/chatbot.ts";
|
} from "../_shared/chatbot.ts";
|
||||||
|
import {
|
||||||
|
getClientIp,
|
||||||
|
getCorsHeaders,
|
||||||
|
hashText,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
verifyInternalRequest,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const MAX_BODY_BYTES = 64 * 1024;
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
const allowedProviders = new Set<ProviderName>(["telegram", "vk", "messenger_max"]);
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
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 {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const provider = url.searchParams.get("provider") as ProviderName | null;
|
const provider = url.searchParams.get("provider") as ProviderName | null;
|
||||||
if (!provider) {
|
if (!provider || !allowedProviders.has(provider)) {
|
||||||
return json({ error: "provider is required" }, 400);
|
return json({ error: "provider is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json()) as Record<string, unknown>;
|
const { body, rawBody } = await readJsonBody<Record<string, unknown>>(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);
|
const event = normalizeIncomingEvent(provider, body);
|
||||||
if (!event.orderId) {
|
if (!event.orderId) {
|
||||||
return json({ error: "order_id is required" }, 400);
|
return json({ error: "order_id is required" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
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 orderUpdate = orderUpdateByAction(event.action);
|
||||||
|
|
||||||
const messagePayload = {
|
const messagePayload = {
|
||||||
|
|
@ -44,7 +82,7 @@ Deno.serve(async (request) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload);
|
const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload);
|
||||||
if (messageError) {
|
if (messageError && messageError.code !== "23505") {
|
||||||
throw messageError;
|
throw messageError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,24 +127,15 @@ Deno.serve(async (request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
headers: {
|
headers: corsHeaders,
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
return json(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
500,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,49 +2,89 @@ import {
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
getOrderUpdateForDeliveryInvitationAction,
|
||||||
hashInvitationToken,
|
hashInvitationToken,
|
||||||
isActiveInvitationState,
|
isActiveInvitationState,
|
||||||
|
isInvitationExpired,
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
|
import {
|
||||||
|
getClientIp,
|
||||||
|
getCorsHeaders,
|
||||||
|
hashText,
|
||||||
|
jsonResponse,
|
||||||
|
preflightResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
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) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
return preflightResponse(request, "public");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
if (request.method !== "POST") {
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
||||||
status: 405,
|
}
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
const corsHeaders = getCorsHeaders(request, "public");
|
||||||
"Content-Type": "application/json",
|
if (!corsHeaders) {
|
||||||
},
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as {
|
const { body } = await readJsonBody<ConfirmBody>(request, {
|
||||||
token?: string;
|
maxBytes: MAX_BODY_BYTES,
|
||||||
deliveryDate?: string;
|
});
|
||||||
deliveryTime?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!body.token) {
|
if (!body.token) {
|
||||||
return new Response(JSON.stringify({ error: "token is required" }), {
|
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
const tokenHash = await hashInvitationToken(body.token);
|
||||||
const supabase = createServiceClient();
|
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
|
const { data: invitation, error: invitationError } = await supabase
|
||||||
.from("delivery_invitations")
|
.from("delivery_invitations")
|
||||||
|
|
@ -54,21 +94,16 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
if (invitationError) {
|
if (invitationError) {
|
||||||
if (invitationError.code === "PGRST116") {
|
if (invitationError.code === "PGRST116") {
|
||||||
return new Response(
|
return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
|
||||||
JSON.stringify({ ok: false, error: "Invitation not found" }),
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw invitationError;
|
throw invitationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInvitationExpired(invitation)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, status, delivery_agreement_status")
|
.select("id, status, delivery_agreement_status")
|
||||||
|
|
@ -80,32 +115,39 @@ Deno.serve(async (request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) {
|
if (!isActiveInvitationState(invitation.state) || !["Ожидает ответа клиента", "Ожидает согласования доставки"].includes(currentOrder.status)) {
|
||||||
return new Response(
|
return jsonResponse(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "Invitation is no longer active",
|
error: "Invitation is no longer active",
|
||||||
}),
|
},
|
||||||
|
409,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedSlot = resolveRequestedSlot(invitation, body);
|
||||||
|
if (!requestedSlot) {
|
||||||
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
status: 409,
|
ok: false,
|
||||||
headers: {
|
error: "Selected slot is not available",
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
422,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("confirm_delivery_choice");
|
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
|
const { error: invitationUpdateError } = await supabase
|
||||||
.from("delivery_invitations")
|
.from("delivery_invitations")
|
||||||
.update({
|
.update({
|
||||||
state: "agreed",
|
state: "agreed",
|
||||||
delivery_date: deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
confirmed_at: new Date().toISOString(),
|
confirmed_at: new Date().toISOString(),
|
||||||
|
access_count: (invitation.access_count || 0) + 1,
|
||||||
|
last_accessed_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq("id", invitation.id);
|
.eq("id", invitation.id);
|
||||||
|
|
||||||
|
|
@ -127,8 +169,8 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const { error: slotError } = await supabase.from("delivery_slots").insert({
|
const { error: slotError } = await supabase.from("delivery_slots").insert({
|
||||||
order_id: invitation.order_id,
|
order_id: invitation.order_id,
|
||||||
delivery_date: deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
logistician_id: null,
|
logistician_id: null,
|
||||||
status: "confirmed_by_client",
|
status: "confirmed_by_client",
|
||||||
});
|
});
|
||||||
|
|
@ -145,8 +187,8 @@ Deno.serve(async (request) => {
|
||||||
metadata: {
|
metadata: {
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||||
delivery_date: deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,38 +202,34 @@ Deno.serve(async (request) => {
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
status: "success",
|
status: "success",
|
||||||
payload: {
|
payload: {
|
||||||
delivery_date: deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return jsonResponse(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
orderId: invitation.order_id,
|
orderId: invitation.order_id,
|
||||||
status: orderUpdate?.status,
|
status: orderUpdate?.status,
|
||||||
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
|
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
if (error instanceof Error && "status" in error) {
|
||||||
JSON.stringify({
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,49 +8,68 @@ import {
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts";
|
import { channelFromProvider, createServiceClient, json } from "../_shared/chatbot.ts";
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
|
import {
|
||||||
|
getClientIp,
|
||||||
|
getCorsHeaders,
|
||||||
|
jsonResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
verifyInternalRequest,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const MAX_BODY_BYTES = 16 * 1024;
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
type CreateInvitationBody = {
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return new Response("ok", { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
||||||
status: 405,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = (await request.json()) as {
|
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
orderNumber?: string;
|
orderNumber?: string;
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
customerPhone?: string;
|
customerPhone?: string;
|
||||||
customerMessenger?: string;
|
customerMessenger?: string;
|
||||||
availableSlots?: string[];
|
availableSlots?: string[];
|
||||||
};
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
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 jsonResponse({ error: "Method not allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { body, rawBody } = await readJsonBody<CreateInvitationBody>(request, {
|
||||||
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
});
|
||||||
|
const auth = await verifyInternalRequest(request, rawBody, {
|
||||||
|
rawBody,
|
||||||
|
allowedClockSkewSeconds: 300,
|
||||||
|
});
|
||||||
|
|
||||||
if (!body.orderId) {
|
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 token = generateInvitationToken();
|
||||||
const tokenHash = await hashInvitationToken(token);
|
const tokenHash = await hashInvitationToken(token);
|
||||||
const supabase = createServiceClient();
|
|
||||||
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation");
|
const orderUpdate = getOrderUpdateForDeliveryInvitationAction("create_delivery_invitation");
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
.from("orders")
|
.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)
|
.eq("id", body.orderId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|
@ -60,7 +79,9 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
||||||
.from("delivery_invitations")
|
.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)
|
.eq("order_id", body.orderId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
|
|
@ -69,8 +90,8 @@ Deno.serve(async (request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentOrder.delivery_flow_started_at || existingInvitation) {
|
if (currentOrder.delivery_flow_started_at || existingInvitation) {
|
||||||
return new Response(
|
return jsonResponse(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
alreadyStarted: true,
|
alreadyStarted: true,
|
||||||
invitation: existingInvitation
|
invitation: existingInvitation
|
||||||
|
|
@ -87,13 +108,9 @@ Deno.serve(async (request) => {
|
||||||
orderId: body.orderId,
|
orderId: body.orderId,
|
||||||
state: "awaiting_choice",
|
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_phone: body.customerPhone || null,
|
||||||
customer_messenger: body.customerMessenger || null,
|
customer_messenger: body.customerMessenger || null,
|
||||||
available_slots: normalizeAvailableSlots(body.availableSlots),
|
available_slots: normalizeAvailableSlots(body.availableSlots),
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
sent_at: new Date().toISOString(),
|
sent_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -139,6 +157,7 @@ Deno.serve(async (request) => {
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||||
channel: channelFromProvider("telegram"),
|
channel: channelFromProvider("telegram"),
|
||||||
|
auth: auth.authenticatedBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,8 +178,8 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const publicBaseUrl = resolvePublicAppUrl(request);
|
const publicBaseUrl = resolvePublicAppUrl(request);
|
||||||
|
|
||||||
return new Response(
|
return jsonResponse(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
invitation: {
|
invitation: {
|
||||||
orderId: body.orderId,
|
orderId: body.orderId,
|
||||||
|
|
@ -169,27 +188,23 @@ Deno.serve(async (request) => {
|
||||||
state: "awaiting_choice",
|
state: "awaiting_choice",
|
||||||
availableSlots: invitationPayload.available_slots,
|
availableSlots: invitationPayload.available_slots,
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
if (error instanceof Error && "status" in error) {
|
||||||
JSON.stringify({
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,68 @@
|
||||||
import {
|
import {
|
||||||
|
buildPublicInvitationView,
|
||||||
getClientInvitationStateFromOrderStatus,
|
getClientInvitationStateFromOrderStatus,
|
||||||
hashInvitationToken,
|
hashInvitationToken,
|
||||||
isActiveInvitationState,
|
isActiveInvitationState,
|
||||||
|
isInvitationExpired,
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
|
import {
|
||||||
|
getClientIp,
|
||||||
|
getCorsHeaders,
|
||||||
|
hashText,
|
||||||
|
jsonResponse,
|
||||||
|
preflightResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const normalizeOrderItems = (
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
items: unknown,
|
|
||||||
): Array<{ name: string; quantity?: string }> => {
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
type InvitationBody = {
|
||||||
.map((item) => {
|
token?: string;
|
||||||
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));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const corsHeaders = {
|
const getTokenFromRequest = async (request: Request) => {
|
||||||
"Access-Control-Allow-Origin": "*",
|
if (request.method === "GET") {
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
return new URL(request.url).searchParams.get("token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body } = await readJsonBody<InvitationBody>(request, {
|
||||||
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
});
|
||||||
|
return String(body.token || "").trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
return preflightResponse(request, "public");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!["GET", "POST"].includes(request.method)) {
|
if (!["GET", "POST"].includes(request.method)) {
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
||||||
status: 405,
|
}
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
const corsHeaders = getCorsHeaders(request, "public");
|
||||||
"Content-Type": "application/json",
|
if (!corsHeaders) {
|
||||||
},
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const token = await getTokenFromRequest(request);
|
||||||
const token = request.method === "POST"
|
|
||||||
? ((await request.json()) as { token?: string })?.token || ""
|
|
||||||
: url.searchParams.get("token") || "";
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return new Response(JSON.stringify({ error: "token is required" }), {
|
return jsonResponse({ ok: false, error: "token is required" }, 400, corsHeaders);
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(token);
|
const tokenHash = await hashInvitationToken(token);
|
||||||
const supabase = createServiceClient();
|
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
|
const { data: invitation, error: invitationError } = await supabase
|
||||||
.from("delivery_invitations")
|
.from("delivery_invitations")
|
||||||
|
|
@ -95,21 +72,16 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
if (invitationError) {
|
if (invitationError) {
|
||||||
if (invitationError.code === "PGRST116") {
|
if (invitationError.code === "PGRST116") {
|
||||||
return new Response(
|
return jsonResponse({ ok: false, error: "Invitation not found" }, 404, corsHeaders);
|
||||||
JSON.stringify({ ok: false, error: "Invitation not found" }),
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw invitationError;
|
throw invitationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInvitationExpired(invitation)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: order, error: orderError } = await supabase
|
const { data: order, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, order_number, status, delivery_agreement_status, customer")
|
.select("id, order_number, status, delivery_agreement_status, customer")
|
||||||
|
|
@ -127,48 +99,47 @@ Deno.serve(async (request) => {
|
||||||
.from("delivery_invitations")
|
.from("delivery_invitations")
|
||||||
.update({
|
.update({
|
||||||
opened_at: new Date().toISOString(),
|
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);
|
.eq("id", invitation.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(
|
const invitationView = buildPublicInvitationView(invitation, order);
|
||||||
JSON.stringify({
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
invitation: {
|
invitation: {
|
||||||
orderId: invitation.order_id,
|
...invitationView,
|
||||||
|
token,
|
||||||
state: publicState,
|
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) {
|
} catch (error) {
|
||||||
return new Response(
|
if (error instanceof Error && "status" in error) {
|
||||||
JSON.stringify({
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,46 +3,54 @@ import {
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
|
import {
|
||||||
|
getCorsHeaders,
|
||||||
|
jsonResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
verifyInternalRequest,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const MAX_BODY_BYTES = 16 * 1024;
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
type ReportBody = {
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return new Response("ok", { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
||||||
status: 405,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = (await request.json()) as {
|
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
result?: "delivered" | "problem";
|
result?: "delivered" | "problem";
|
||||||
note?: string;
|
note?: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
const corsHeaders = getCorsHeaders(request, "integration");
|
||||||
|
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { body, rawBody } = await readJsonBody<ReportBody>(request, {
|
||||||
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
});
|
||||||
|
await verifyInternalRequest(request, rawBody, { rawBody });
|
||||||
|
|
||||||
if (!body.orderId) {
|
if (!body.orderId) {
|
||||||
return new Response(JSON.stringify({ error: "orderId is required" }), {
|
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
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
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, status, delivery_agreement_status")
|
.select("id, status, delivery_agreement_status")
|
||||||
|
|
@ -114,8 +122,8 @@ Deno.serve(async (request) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return jsonResponse(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
orderId: body.orderId,
|
orderId: body.orderId,
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
|
|
@ -123,27 +131,23 @@ Deno.serve(async (request) => {
|
||||||
? "Подтверждено клиентом"
|
? "Подтверждено клиентом"
|
||||||
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
|
: body.note || currentOrder.delivery_agreement_status || "Ошибка отправки",
|
||||||
workflowStatus: nextStatus,
|
workflowStatus: nextStatus,
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
if (error instanceof Error && "status" in error) {
|
||||||
JSON.stringify({
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,12 @@ import {
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
} from "../_shared/chatbot.ts";
|
} from "../_shared/chatbot.ts";
|
||||||
import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts";
|
import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts";
|
||||||
|
import {
|
||||||
|
getCorsHeaders,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
verifyInternalRequest,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const providerTokens: Record<ProviderName, string | undefined> = {
|
const providerTokens: Record<ProviderName, string | undefined> = {
|
||||||
telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"),
|
telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"),
|
||||||
|
|
@ -12,6 +18,8 @@ const providerTokens: Record<ProviderName, string | undefined> = {
|
||||||
messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"),
|
messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
|
||||||
const sendToProvider = async ({
|
const sendToProvider = async ({
|
||||||
provider,
|
provider,
|
||||||
recipientId,
|
recipientId,
|
||||||
|
|
@ -38,22 +46,41 @@ const sendToProvider = async ({
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
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") {
|
if (request.method !== "POST") {
|
||||||
return json({ error: "Method not allowed" }, 405);
|
return json({ error: "Method not allowed" }, 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as {
|
const { body, rawBody } = await readJsonBody<{
|
||||||
provider: ProviderName;
|
provider: ProviderName;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
recipientId: string;
|
recipientId: string;
|
||||||
text: string;
|
text: string;
|
||||||
buttons?: Array<{ title: string; action: string }>;
|
buttons?: Array<{ title: string; action: string }>;
|
||||||
workflowAction?: OutboundWorkflowAction;
|
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 dispatchResult = await sendToProvider(body);
|
||||||
const supabase = createServiceClient();
|
|
||||||
|
|
||||||
const { error } = await supabase.from("chat_messages").insert({
|
const { error } = await supabase.from("chat_messages").insert({
|
||||||
order_id: body.orderId,
|
order_id: body.orderId,
|
||||||
|
|
|
||||||
|
|
@ -3,46 +3,54 @@ import {
|
||||||
} from "../_shared/delivery-invitations.ts";
|
} from "../_shared/delivery-invitations.ts";
|
||||||
import { createServiceClient } from "../_shared/chatbot.ts";
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
|
import {
|
||||||
|
getCorsHeaders,
|
||||||
|
jsonResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
verifyInternalRequest,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
const corsHeaders = {
|
const MAX_BODY_BYTES = 16 * 1024;
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
||||||
};
|
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
type TransferBody = {
|
||||||
if (request.method === "OPTIONS") {
|
|
||||||
return new Response("ok", { headers: corsHeaders });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.method !== "POST") {
|
|
||||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
||||||
status: 405,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = (await request.json()) as {
|
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
targetStatus?: "Передан логисту" | "Платное хранение";
|
targetStatus?: "Передан логисту" | "Платное хранение";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
const corsHeaders = getCorsHeaders(request, "integration");
|
||||||
|
return corsHeaders ? new Response("ok", { headers: corsHeaders }) : jsonResponse({ error: "Origin not allowed" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return jsonResponse({ error: "Method not allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsHeaders = getCorsHeaders(request, "integration") || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { body, rawBody } = await readJsonBody<TransferBody>(request, {
|
||||||
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
});
|
||||||
|
await verifyInternalRequest(request, rawBody, { rawBody });
|
||||||
|
|
||||||
if (!body.orderId) {
|
if (!body.orderId) {
|
||||||
return new Response(JSON.stringify({ error: "orderId is required" }), {
|
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServiceClient();
|
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
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, status, delivery_agreement_status")
|
.select("id, status, delivery_agreement_status")
|
||||||
|
|
@ -113,33 +121,29 @@ Deno.serve(async (request) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return jsonResponse(
|
||||||
JSON.stringify({
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
orderId: body.orderId,
|
orderId: body.orderId,
|
||||||
status: orderUpdate?.status,
|
status: orderUpdate?.status,
|
||||||
deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus,
|
deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus,
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(
|
if (error instanceof Error && "status" in error) {
|
||||||
JSON.stringify({
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: error instanceof Error ? error.message : "Unexpected error",
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -95,6 +95,10 @@ create table if not exists public.delivery_invitations (
|
||||||
customer_phone text,
|
customer_phone text,
|
||||||
customer_messenger text,
|
customer_messenger text,
|
||||||
available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'],
|
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_date date,
|
||||||
delivery_time text,
|
delivery_time text,
|
||||||
sent_at timestamptz,
|
sent_at timestamptz,
|
||||||
|
|
@ -119,6 +123,18 @@ create table if not exists public.integration_events (
|
||||||
created_at timestamptz not null default timezone('utc', now())
|
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 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 assigned_driver_id uuid references public.users (id);
|
||||||
alter table public.orders add column if not exists ready_for_delivery_at timestamptz;
|
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'));
|
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 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_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 delivery_time text;
|
||||||
alter table public.delivery_invitations add column if not exists sent_at timestamptz;
|
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 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 payload jsonb not null default '{}'::jsonb;
|
||||||
alter table public.integration_events add column if not exists error_message text;
|
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_key on public.orders (delivery_set_key);
|
||||||
create index if not exists idx_orders_delivery_set_status on public.orders (delivery_set_status);
|
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_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_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_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_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_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.roles enable row level security;
|
||||||
alter table public.users 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;
|
drop policy if exists "users self or admin" on public.users;
|
||||||
create policy "users self or admin" on public.users
|
create policy "users self or admin" on public.users
|
||||||
for select
|
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;
|
drop policy if exists "users admin update" on public.users;
|
||||||
create policy "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')
|
using (public.current_role_name() = 'admin')
|
||||||
with check (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;
|
drop policy if exists "integration events admin only" on public.integration_events;
|
||||||
create policy "integration events admin only" on public.integration_events
|
create policy "integration events admin only" on public.integration_events
|
||||||
for all
|
for all
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue