diff --git a/app/Enums/SocialAccount/TelegramConnectStatus.php b/app/Enums/SocialAccount/TelegramConnectStatus.php index 3afeb0bc..56c006f9 100644 --- a/app/Enums/SocialAccount/TelegramConnectStatus.php +++ b/app/Enums/SocialAccount/TelegramConnectStatus.php @@ -4,25 +4,19 @@ namespace App\Enums\SocialAccount; -use App\Models\TelegramConnectRequest; +use App\Models\SocialAccount; enum TelegramConnectStatus: string { case Unknown = 'unknown'; case Pending = 'pending'; case Connected = 'connected'; - case Expired = 'expired'; /** - * Derive the connection status the frontend polls for from a connect request. + * Connected once the channel has been linked to an account; otherwise still pending. */ - public static function for(?TelegramConnectRequest $request): self + public static function for(?SocialAccount $account): self { - return match (true) { - $request === null => self::Unknown, - $request->social_account_id !== null => self::Connected, - $request->isExpired() => self::Expired, - default => self::Pending, - }; + return $account === null ? self::Pending : self::Connected; } } diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index a6367822..e7fd8202 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -6,20 +6,21 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\TelegramConnectStatus; -use App\Http\Requests\App\Auth\TelegramStatusRequest; -use App\Models\TelegramConnectRequest; +use App\Services\Social\TelegramConnectCode; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class TelegramController extends SocialController { protected SocialPlatform $platform = SocialPlatform::Telegram; + private const SESSION_KEY = 'telegram_connect_code'; + /** - * Start a connection: issue a one-time code the user posts in their channel - * (`/connect `) so the webhook can tie the channel to this workspace. + * Start a connection: issue a signed one-off code the user posts in their + * channel (`/connect `). The code carries the workspace, so the webhook + * can link the channel without any persisted state. */ public function connect(Request $request): JsonResponse { @@ -31,35 +32,39 @@ public function connect(Request $request): JsonResponse $this->authorize('manageAccounts', $workspace); $this->ensureSocialAccountLimit($workspace); - $connectRequest = TelegramConnectRequest::create([ - 'workspace_id' => $workspace->id, - 'user_id' => $request->user()->id, - 'code' => Str::lower(Str::random(12)), - 'expires_at' => now()->addMinutes(15), - ]); + $expiresAt = now()->addMinutes(15); + $code = TelegramConnectCode::issue($workspace->id, $expiresAt); + + $request->session()->put(self::SESSION_KEY, $code); return response()->json([ - 'code' => $connectRequest->code, + 'code' => $code, 'bot_username' => config('trypost.platforms.telegram.bot_username'), - 'expires_at' => $connectRequest->expires_at->toIso8601String(), + 'expires_at' => $expiresAt->toIso8601String(), ]); } /** - * Poll whether the channel has been linked yet. + * Poll whether the channel issued in this session has been linked yet. */ - public function status(TelegramStatusRequest $request): JsonResponse + public function status(Request $request): JsonResponse { $workspace = $request->user()->currentWorkspace; abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.'); - $connectRequest = TelegramConnectRequest::query() - ->where('workspace_id', $workspace->id) - ->where('code', $request->validated('code')) + $payload = TelegramConnectCode::decode($request->session()->get(self::SESSION_KEY)); + + if ($payload === null) { + return response()->json(['status' => TelegramConnectStatus::Unknown->value]); + } + + $account = $workspace->socialAccounts() + ->where('platform', SocialPlatform::Telegram->value) + ->where('meta->connect_nonce', data_get($payload, 'nonce')) ->first(); return response()->json([ - 'status' => TelegramConnectStatus::for($connectRequest)->value, + 'status' => TelegramConnectStatus::for($account)->value, ]); } } diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index 6b281df4..4d673c45 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -8,8 +8,8 @@ use App\Enums\SocialAccount\Status; use App\Features\SocialAccountLimit; use App\Http\Controllers\Controller; -use App\Models\TelegramConnectRequest; use App\Models\Workspace; +use App\Services\Social\TelegramConnectCode; use Illuminate\Http\Request; use Illuminate\Http\Response; use Laravel\Pennant\Feature; @@ -19,8 +19,8 @@ class TelegramWebhookController extends Controller { /** * Receives Bot API updates. The only update we act on is a `/connect ` - * message/channel_post: it ties the originating channel to the workspace that - * generated the code. Everything else is acknowledged and ignored. + * message/channel_post: the signed code carries the workspace, so we link the + * originating channel to it. Everything else is acknowledged and ignored. */ public function handle(Request $request): Response { @@ -39,21 +39,16 @@ public function handle(Request $request): Response return response()->noContent(); } - $connectRequest = TelegramConnectRequest::query() - ->whereNull('social_account_id') - ->where('code', $matches[1]) - ->where('expires_at', '>', now()) - ->first(); + $payload = TelegramConnectCode::decode($matches[1]); + $workspace = $payload === null ? null : Workspace::find(data_get($payload, 'workspace_id')); - if ($connectRequest === null) { + if ($workspace === null) { return response()->noContent(); } $chatId = (string) data_get($chat, 'id'); $username = data_get($chat, 'username'); - $workspace = $connectRequest->workspace; - // Mirror the controller's limit gate: block only brand-new accounts, never reconnects. $isNewAccount = ! $workspace->socialAccounts() ->where('platform', SocialPlatform::Telegram->value) @@ -64,7 +59,7 @@ public function handle(Request $request): Response return response()->noContent(); } - $account = $workspace->socialAccounts()->updateOrCreate( + $workspace->socialAccounts()->updateOrCreate( [ 'platform' => SocialPlatform::Telegram->value, 'platform_user_id' => $chatId, @@ -83,12 +78,11 @@ public function handle(Request $request): Response 'chat_id' => $chatId, 'username' => $username, 'type' => data_get($chat, 'type'), + 'connect_nonce' => data_get($payload, 'nonce'), ], ], ); - $connectRequest->update(['social_account_id' => $account->id]); - return response()->noContent(); } diff --git a/app/Http/Requests/App/Auth/TelegramStatusRequest.php b/app/Http/Requests/App/Auth/TelegramStatusRequest.php deleted file mode 100644 index 3810e203..00000000 --- a/app/Http/Requests/App/Auth/TelegramStatusRequest.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'code' => ['required', 'string'], - ]; - } -} diff --git a/app/Models/TelegramConnectRequest.php b/app/Models/TelegramConnectRequest.php deleted file mode 100644 index 5f44a283..00000000 --- a/app/Models/TelegramConnectRequest.php +++ /dev/null @@ -1,40 +0,0 @@ - 'datetime', - ]; - } - - public function isExpired(): bool - { - return $this->expires_at->isPast(); - } - - public function workspace(): BelongsTo - { - return $this->belongsTo(Workspace::class); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/app/Services/Social/TelegramConnectCode.php b/app/Services/Social/TelegramConnectCode.php new file mode 100644 index 00000000..97cb45f5 --- /dev/null +++ b/app/Services/Social/TelegramConnectCode.php @@ -0,0 +1,53 @@ + $workspaceId, + 'nonce' => Str::lower(Str::random(16)), + 'expires_at' => $expiresAt->getTimestamp(), + ])); + } + + /** + * Decode and validate a code, returning its payload or null when the code is + * missing, tampered with, or expired. + * + * @return array{workspace_id: string, nonce: string, expires_at: int}|null + */ + public static function decode(?string $code): ?array + { + if (! is_string($code) || $code === '') { + return null; + } + + try { + $payload = json_decode(Crypt::decryptString($code), true); + } catch (DecryptException) { + return null; + } + + if (! is_array($payload) || now()->getTimestamp() > (int) data_get($payload, 'expires_at')) { + return null; + } + + return $payload; + } +} diff --git a/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php b/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php deleted file mode 100644 index ed34310a..00000000 --- a/database/migrations/2026_06_14_002757_create_telegram_connect_requests_table.php +++ /dev/null @@ -1,29 +0,0 @@ -uuid('id')->primary(); - $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); - $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); - $table->string('code')->unique(); - // Set by the webhook once the channel is linked; null while pending. - $table->foreignUuid('social_account_id')->nullable()->constrained('social_accounts')->nullOnDelete(); - $table->timestamp('expires_at'); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('telegram_connect_requests'); - } -}; diff --git a/resources/js/components/accounts/TelegramConnectDialog.vue b/resources/js/components/accounts/TelegramConnectDialog.vue index d295a372..57c701e3 100644 --- a/resources/js/components/accounts/TelegramConnectDialog.vue +++ b/resources/js/components/accounts/TelegramConnectDialog.vue @@ -23,7 +23,7 @@ import { const open = defineModel('open', { required: true }); type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error'; -type ConnectStatus = 'unknown' | 'pending' | 'connected' | 'expired'; +type ConnectStatus = 'unknown' | 'pending' | 'connected'; interface ConnectResponse { code: string; @@ -57,9 +57,7 @@ const poll = async () => { if (phase.value !== 'ready') return; try { - const response = await httpStatus.get( - telegramStatus.url({ query: { code: code.value } }), - ); + const response = await httpStatus.get(telegramStatus.url()); if (response?.status === 'connected') { phase.value = 'connected'; @@ -72,7 +70,8 @@ const poll = async () => { return; } - if (response?.status === 'expired' || response?.status === 'unknown') { + // The signed code expired (or the session was lost): prompt a fresh one. + if (response?.status === 'unknown') { phase.value = 'expired'; stopPolling(); return; diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php index 81bbefee..4bd19bb2 100644 --- a/tests/Feature/Social/TelegramConnectionTest.php +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -5,10 +5,10 @@ use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; use App\Models\SocialAccount; -use App\Models\TelegramConnectRequest; use App\Models\User; use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; +use App\Services\Social\TelegramConnectCode; use Illuminate\Support\Facades\Http; beforeEach(function () { @@ -43,31 +43,23 @@ function telegramUpdate(string $code, array $chat = []): array ]; } -it('issues a connect code', function () { +it('issues a signed connect code and stores it in the session', function () { $response = $this->actingAs($this->user) ->postJson(route('app.social.telegram.connect')) ->assertOk() - ->assertJsonStructure(['code', 'bot_username', 'expires_at']); + ->assertJsonStructure(['code', 'bot_username', 'expires_at']) + ->assertSessionHas('telegram_connect_code'); expect($response->json('bot_username'))->toBe('TryPostBot'); - - $this->assertDatabaseHas('telegram_connect_requests', [ - 'workspace_id' => $this->workspace->id, - 'code' => $response->json('code'), - 'social_account_id' => null, - ]); + expect(data_get(TelegramConnectCode::decode($response->json('code')), 'workspace_id')) + ->toBe($this->workspace->id); }); it('links the channel when the webhook receives a matching /connect', function () { - $request = TelegramConnectRequest::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'code' => 'abc123code', - 'expires_at' => now()->addMinutes(15), - ]); + $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate('abc123code')) + ->postJson(route('telegram.webhook'), telegramUpdate($code)) ->assertNoContent(); $account = SocialAccount::where('workspace_id', $this->workspace->id) @@ -79,20 +71,15 @@ function telegramUpdate(string $code, array $chat = []): array expect($account->display_name)->toBe('My Channel'); expect($account->username)->toBe('mychannel'); expect(data_get($account->meta, 'chat_id'))->toBe('-1001234567890'); - - expect($request->fresh()->social_account_id)->toBe($account->id); + expect(data_get($account->meta, 'connect_nonce')) + ->toBe(data_get(TelegramConnectCode::decode($code), 'nonce')); }); it('links a private channel that has no username', function () { - TelegramConnectRequest::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'code' => 'privatecode', - 'expires_at' => now()->addMinutes(15), - ]); + $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate('privatecode', ['username' => null])) + ->postJson(route('telegram.webhook'), telegramUpdate($code, ['username' => null])) ->assertNoContent(); $account = SocialAccount::where('platform', Platform::Telegram)->first(); @@ -107,15 +94,10 @@ function telegramUpdate(string $code, array $chat = []): array SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]); - TelegramConnectRequest::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'code' => 'limitcode', - 'expires_at' => now()->addMinutes(15), - ]); + $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate('limitcode')) + ->postJson(route('telegram.webhook'), telegramUpdate($code)) ->assertNoContent(); expect($this->workspace->socialAccounts()->count())->toBe(5); @@ -133,15 +115,10 @@ function telegramUpdate(string $code, array $chat = []): array 'platform_user_id' => '-1001234567890', ]); - TelegramConnectRequest::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'code' => 'reconnectcode', - 'expires_at' => now()->addMinutes(15), - ]); + $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate('reconnectcode')) + ->postJson(route('telegram.webhook'), telegramUpdate($code)) ->assertNoContent(); expect($this->workspace->socialAccounts()->count())->toBe(5); @@ -150,58 +127,56 @@ function telegramUpdate(string $code, array $chat = []): array )->toBe(1); }); -it('requires a code to check connection status', function () { - $this->actingAs($this->user) - ->getJson(route('app.social.telegram.status')) - ->assertStatus(422); -}); - it('rejects the webhook without the secret token', function () { - $this->postJson(route('telegram.webhook'), telegramUpdate('whatever')) + $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); + + $this->postJson(route('telegram.webhook'), telegramUpdate($code)) ->assertForbidden(); }); -it('ignores the webhook for an unknown or expired code', function () { - TelegramConnectRequest::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'code' => 'expiredcode', - 'expires_at' => now()->subMinute(), - ]); +it('ignores the webhook for a tampered or expired code', function () { + $expired = TelegramConnectCode::issue($this->workspace->id, now()->subMinute()); $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate('expiredcode')) + ->postJson(route('telegram.webhook'), telegramUpdate($expired)) ->assertNoContent(); $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate('does-not-exist')) + ->postJson(route('telegram.webhook'), telegramUpdate('not-a-valid-code')) ->assertNoContent(); expect(SocialAccount::where('platform', Platform::Telegram)->count())->toBe(0); }); -it('reports connection status while pending and once connected', function () { - $request = TelegramConnectRequest::create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - 'code' => 'statuscode', - 'expires_at' => now()->addMinutes(15), - ]); +it('reports the session connection status while pending and once connected', function () { + $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); + $nonce = data_get(TelegramConnectCode::decode($code), 'nonce'); $this->actingAs($this->user) - ->getJson(route('app.social.telegram.status', ['code' => 'statuscode'])) + ->withSession(['telegram_connect_code' => $code]) + ->getJson(route('app.social.telegram.status')) ->assertOk() ->assertJson(['status' => 'pending']); - $account = SocialAccount::factory()->telegram()->create(['workspace_id' => $this->workspace->id]); - $request->update(['social_account_id' => $account->id]); + SocialAccount::factory()->telegram()->create([ + 'workspace_id' => $this->workspace->id, + 'meta' => ['chat_id' => '-1001234567890', 'username' => 'mychannel', 'type' => 'channel', 'connect_nonce' => $nonce], + ]); $this->actingAs($this->user) - ->getJson(route('app.social.telegram.status', ['code' => 'statuscode'])) + ->withSession(['telegram_connect_code' => $code]) + ->getJson(route('app.social.telegram.status')) ->assertOk() ->assertJson(['status' => 'connected']); }); +it('reports unknown status without a session code', function () { + $this->actingAs($this->user) + ->getJson(route('app.social.telegram.status')) + ->assertOk() + ->assertJson(['status' => 'unknown']); +}); + it('verifies a connected telegram account via getChat', function () { config(['trypost.platforms.telegram.bot_token' => 'TESTTOKEN']);