diff --git a/app/app/Http/Controllers/PublicUnsubscribeController.php b/app/app/Http/Controllers/PublicUnsubscribeController.php new file mode 100644 index 00000000..6ae0728c --- /dev/null +++ b/app/app/Http/Controllers/PublicUnsubscribeController.php @@ -0,0 +1,71 @@ +link($token); + + return view('unsubscribe', [ + 'token' => $token, + 'masked' => $this->mask($link->phone), + 'done' => $request->boolean('done'), + ]); + } + + public function store(string $token): RedirectResponse + { + $link = $this->link($token); + + ClientSmsOptout::on(self::CONNECTION)->firstOrCreate( + ['tenant_id' => $link->tenant_id, 'phone' => $link->phone], + ['source' => ClientSmsOptout::SOURCE_RECIPIENT], + ); + + return redirect("/s/{$token}?done=1"); + } + + private function link(string $token): ClientSmsUnsubscribeLink + { + $link = ClientSmsUnsubscribeLink::on(self::CONNECTION)->whereKey($token)->first(); + + if ($link === null) { + throw new NotFoundHttpException; + } + + return $link; + } + + /** 79990000067 → «+7 *** *** ** 67» */ + private function mask(string $phone): string + { + return '+7 *** *** ** '.substr($phone, -2); + } +} diff --git a/app/app/Models/ClientSmsUnsubscribeLink.php b/app/app/Models/ClientSmsUnsubscribeLink.php new file mode 100644 index 00000000..b8ca7d03 --- /dev/null +++ b/app/app/Models/ClientSmsUnsubscribeLink.php @@ -0,0 +1,45 @@ +on(...)`, чтобы обход изоляции был виден в одном месте, а не был + * молча включён для всего модуля. + * + * Источник: db/schema.sql, table `client_sms_unsubscribe_links`. + */ +class ClientSmsUnsubscribeLink extends Model +{ + protected $table = 'client_sms_unsubscribe_links'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + protected $fillable = [ + 'token', + 'tenant_id', + 'phone', + ]; + + protected function casts(): array + { + return [ + 'tenant_id' => 'integer', + 'created_at' => 'immutable_datetime', + ]; + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index fb173f2a..61d58000 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -207,6 +207,11 @@ class AppServiceProvider extends ServiceProvider // защита от накрутки счётчика. RateLimiter::for('track', fn (Request $request) => Limit::perMinute(60)->by($request->ip() ?: 'unknown')); + // sms-unsubscribe (строка листа 1.8): публичная страница отказа `/s/{token}`. + // Ссылка короткая и без авторизации — без лимита перебор токенов становится + // способом собрать чужие номера. 20/мин с IP: живому человеку хватает с запасом. + RateLimiter::for('sms-unsubscribe', fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown')); + RateLimiter::for('api-v1', function (Request $request) { $header = (string) $request->header('Authorization', ''); $bearer = str_starts_with($header, 'Bearer ') diff --git a/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php b/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php new file mode 100644 index 00000000..ee8f81db --- /dev/null +++ b/app/app/Services/ClientSms/ClientSmsUnsubscribeLinkService.php @@ -0,0 +1,57 @@ +where('tenant_id', $tenantId) + ->where('phone', $phone) + ->value('token'); + + if ($existing !== null) { + return (string) $existing; + } + + do { + $token = $this->randomToken(); + } while (ClientSmsUnsubscribeLink::whereKey($token)->exists()); + + ClientSmsUnsubscribeLink::create([ + 'token' => $token, + 'tenant_id' => $tenantId, + 'phone' => $phone, + ]); + + return $token; + } + + private function randomToken(): string + { + $out = ''; + $last = strlen(self::ALPHABET) - 1; + + for ($i = 0; $i < self::LENGTH; $i++) { + $out .= self::ALPHABET[random_int(0, $last)]; + } + + return $out; + } +} diff --git a/app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php b/app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php new file mode 100644 index 00000000..62c04a62 --- /dev/null +++ b/app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php @@ -0,0 +1,44 @@ +string('token', 12)->primary(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('phone', 11); + $table->timestampTz('created_at')->useCurrent(); + + $table->unique(['tenant_id', 'phone']); + }); + + DB::statement('ALTER TABLE client_sms_unsubscribe_links ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE client_sms_unsubscribe_links FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_sms_unsubscribe_links'); + DB::statement("CREATE POLICY tenant_isolation ON client_sms_unsubscribe_links USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT ON client_sms_unsubscribe_links TO crm_app_user'); + DB::statement('GRANT SELECT ON client_sms_unsubscribe_links TO crm_supplier_worker'); + } + + public function down(): void + { + Schema::dropIfExists('client_sms_unsubscribe_links'); + } +}; diff --git a/app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php b/app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php new file mode 100644 index 00000000..cd67245f --- /dev/null +++ b/app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php @@ -0,0 +1,33 @@ + + + + + + + Отказ от СМС-рассылки + + + +
+ @if ($done) +

Готово

+

Больше не напишем на номер {{ $masked }}.

+ @else +

Отказ от СМС-рассылки

+

Номер {{ $masked }}

+
+ @csrf + +
+

Отказ действует только на рассылки той компании, от которой пришло сообщение.

+ @endif +
+ + diff --git a/app/routes/web.php b/app/routes/web.php index efd781ca..989fe1bb 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -665,6 +665,16 @@ Route::get('/kak-eto-rabotaet', function () { return response()->file($page, ['Content-Type' => 'text/html; charset=utf-8']); }); +// Публичная страница отказа от СМС-рассылки (строки листа 1.6–1.8). БЕЗ авторизации: +// получатель СМС — не наш пользователь. Ограничение частоты обязательно — перебор +// ссылок иначе превращается в утечку номеров. Регистрируется до fallback'а. +Route::middleware('throttle:sms-unsubscribe')->group(function () { + Route::get('/s/{token}', 'App\Http\Controllers\PublicUnsubscribeController@show') + ->where('token', '[A-Za-z0-9]{12}'); + Route::post('/s/{token}', 'App\Http\Controllers\PublicUnsubscribeController@store') + ->where('token', '[A-Za-z0-9]{12}'); +}); + // Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404. // Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest // beforeEach (они регистрируются в момент теста, до запроса). diff --git a/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php b/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php new file mode 100644 index 00000000..44578b12 --- /dev/null +++ b/app/tests/Feature/ClientSms/PublicUnsubscribeTest.php @@ -0,0 +1,73 @@ +tenant = Tenant::factory()->create(); + $this->token = app(ClientSmsUnsubscribeLinkService::class) + ->tokenFor($this->tenant->id, '79990000067'); +}); + +it('человек по ссылке отказывается — и попадает в стоп-лист того клиента', function () { + $this->post("/s/{$this->token}")->assertRedirect(); + + $row = ClientSmsOptout::where('tenant_id', $this->tenant->id) + ->where('phone', '79990000067') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->source)->toBe(ClientSmsOptout::SOURCE_RECIPIENT); +}); + +it('повторный отказ по той же ссылке не ломается', function () { + $this->post("/s/{$this->token}")->assertRedirect(); + $this->post("/s/{$this->token}")->assertRedirect(); + + expect(ClientSmsOptout::where('tenant_id', $this->tenant->id)->count())->toBe(1); +}); + +it('страница не показывает номер целиком — только маской', function () { + $this->get("/s/{$this->token}") + ->assertOk() + ->assertDontSee('79990000067') + ->assertSee('** 67', false); +}); + +it('неизвестный токен не выдаёт, существует он или нет', function () { + $this->get('/s/zzzzzzzzzzzz')->assertNotFound(); +}); + +it('перебор ссылок упирается в ограничение частоты', function () { + $status = 200; + + foreach (range(1, 30) as $i) { + $status = $this->get('/s/aaaaaaaaaaaa')->status(); + + if ($status === 429) { + break; + } + } + + expect($status)->toBe(429); +}); diff --git a/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php b/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php new file mode 100644 index 00000000..c30719a1 --- /dev/null +++ b/app/tests/Feature/ClientSms/UnsubscribeLinkTest.php @@ -0,0 +1,59 @@ +tenant = Tenant::factory()->create(); + $this->otherTenant = Tenant::factory()->create(); + $this->service = app(ClientSmsUnsubscribeLinkService::class); +}); + +it('токен на пару «клиент + номер» постоянный', function () { + $first = $this->service->tokenFor($this->tenant->id, '79990000001'); + $second = $this->service->tokenFor($this->tenant->id, '79990000001'); + + expect($second)->toBe($first) + ->and(strlen($first))->toBe(12) + ->and(ClientSmsUnsubscribeLink::count())->toBe(1); +}); + +it('у разных клиентов на один номер разные ссылки', function () { + $mine = $this->service->tokenFor($this->tenant->id, '79990000001'); + $theirs = $this->service->tokenFor($this->otherTenant->id, '79990000001'); + + expect($theirs)->not->toBe($mine) + ->and(ClientSmsUnsubscribeLink::count())->toBe(2); +}); + +it('по токену находится тот самый клиент и тот самый номер', function () { + $token = $this->service->tokenFor($this->tenant->id, '79990000002'); + + $link = ClientSmsUnsubscribeLink::findOrFail($token); + + expect($link->tenant_id)->toBe($this->tenant->id) + ->and($link->phone)->toBe('79990000002'); +}); + +it('в токене нет букв и цифр, которые путаются при чтении вслух', function () { + $tokens = []; + foreach (range(1, 20) as $i) { + $tokens[] = $this->service->tokenFor($this->tenant->id, '7999000'.str_pad((string) $i, 4, '0', STR_PAD_LEFT)); + } + + // 0/O/o, 1/l/I — человек диктует ссылку по телефону, путаница = чужая страница + expect(implode('', $tokens))->not->toMatch('/[0OolI1]/'); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 9ac8a615..3faab273 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -8,6 +8,44 @@ > параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), > поэтому при сведении они перенумерованы. Содержание не менялось. +## v9.02 (2026-07-30) — Клиентская СМС, Этап 1: короткие ссылки отказа `client_sms_unsubscribe_links` + право служебной роли на стоп-лист + +Две миграции: + +**1. `app/database/migrations/2026_07_30_100200_create_client_sms_unsubscribe_links.php`** +— новая таблица `client_sms_unsubscribe_links`: + +- `token` varchar(12) **PRIMARY KEY** — то, что видно в СМС после `liderra.ru/s/`; +- `tenant_id` bigint FK `tenants` ON DELETE CASCADE; +- `phone` varchar(11); +- `created_at` timestamptz DEFAULT now(); +- `UNIQUE (tenant_id, phone)` — одна ссылка на пару «клиент + номер» навсегда. + +RLS **включён** (`ENABLE` + `FORCE`) с политикой `tenant_isolation` — в таблице +лежит телефон живого человека. GRANT'ы: `crm_app_user` — `SELECT, INSERT` +(создание ссылок идёт из тенант-контекста), `crm_supplier_worker` — `SELECT`. + +**2. `app/database/migrations/2026_07_30_100250_grant_optouts_to_supplier_worker.php`** +— `crm_supplier_worker` получает `SELECT, INSERT` на `client_sms_optouts` +и `USAGE, SELECT` на её последовательность. + +**Зачем служебная роль.** Публичная страница `/s/{token}` открывается без входа +в систему: получатель СМС — не наш пользователь. Клиента она узнаёт ИЗ ссылки, +то есть тенант-контекста в момент запроса нет и быть не может — под обычной +ролью политика изоляции вернула бы «строк нет», а запись отказа получила бы +отказ доступа. Обход изоляции заперт в одном классе +(`PublicUnsubscribeController`, явный `->on('pgsql_supplier')`), а не включён +для всего модуля; `tenant_id` при этом проставляется явно. + +⚠️ **При выкате на управляемый кластер** после накатки миграций ПЕРЕзапустить +`db/03_service_bypass_policies.sql` — политика `srv_bypass` выдаётся скриптом по +списку таблиц с RLS, и новая `client_sms_unsubscribe_links` без этого останется +без cross-tenant доступа служебных ролей. Симптом — тихий ноль (страница отказа +скажет «ссылка не найдена» на живую ссылку), а не ошибка. + +Ветка `feat/client-sms-broadcast`, в main не влита. Номер записи при сведении +может быть перенумерован. + ## v9.01 (2026-07-30) — Клиентская СМС, Этап 1: у отказа появился источник и комментарий (`client_sms_optouts`) Миграция `app/database/migrations/2026_07_30_100100_add_source_to_client_sms_optouts.php`