diff --git a/app/Http/Controllers/Api/CallController.php b/app/Http/Controllers/Api/CallController.php new file mode 100644 index 000000000..ad231fbe3 --- /dev/null +++ b/app/Http/Controllers/Api/CallController.php @@ -0,0 +1,31 @@ +incoming($user); + + return response()->json([ + 'call' => $call === null ? null : [ + 'partner' => UserResource::make($call['partner']), + 'sdp' => $call['sdp'], + 'is_muted' => $call['is_muted'], + ], + ])->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Controllers/Api/ConversationCallController.php b/app/Http/Controllers/Api/ConversationCallController.php new file mode 100644 index 000000000..62288f054 --- /dev/null +++ b/app/Http/Controllers/Api/ConversationCallController.php @@ -0,0 +1,59 @@ +json(['call' => $calls->outgoing($user, $partner)])->header('Cache-Control', 'private, no-store'); + } + + /** + * Rings the partner with the app's offer, which works the same whether they answer on the web or in the app. + */ + public function store(CreateApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, CreateConversationCall $action): Response + { + $action->handle($user, $partner, $request->string('sdp')->value()); + + return response()->noContent(); + } + + /** + * Answers the partner's call with the app's answer. + */ + public function update(UpdateApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, UpdateConversationCall $action): Response + { + $action->handle($user, $partner, $request->call(), $request->string('sdp')->value()); + + return response()->noContent(); + } + + /** + * Ends the call between the two, whichever of them rang, as the web does. + */ + public function destroy(DeleteApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, DeleteConversationCall $action): Response + { + $action->handle($user, $partner, $request->unanswered()); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/ConversationCallIceServerController.php b/app/Http/Controllers/Api/ConversationCallIceServerController.php new file mode 100644 index 000000000..93ec07f05 --- /dev/null +++ b/app/Http/Controllers/Api/ConversationCallIceServerController.php @@ -0,0 +1,25 @@ +json([ + 'ice_servers' => $turn->iceServers(), + 'is_relay_only' => $turn->isRelayOnly(), + ])->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Requests/CreateApiConversationCallRequest.php b/app/Http/Requests/CreateApiConversationCallRequest.php new file mode 100644 index 000000000..4cecc1266 --- /dev/null +++ b/app/Http/Requests/CreateApiConversationCallRequest.php @@ -0,0 +1,62 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + if (! $user->canMessage($partner)) { + return Response::denyAsNotFound(); + } + + if (! $turn->isAvailable()) { + return Response::deny("Calls aren't available right now."); + } + + if ($orgCalls->isInOne($user)) { + return Response::deny("You're already on a call."); + } + + if ($access->get($user, $partner) !== PushAccess::Open || $calls->get($partner)?->isFrom($user) === false || $orgCalls->isInOne($partner)) { + return Response::deny("You can't call {$partner->mention()} right now."); + } + + return Conversation::between($user, $partner) instanceof Conversation + ? Response::allow() + : Response::deny("Say hi to {$partner->mention()} before calling them."); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'sdp' => ['required', 'string', 'starts_with:v=0', 'max:10000'], + ]; + } +} diff --git a/app/Http/Requests/DeleteApiConversationCallRequest.php b/app/Http/Requests/DeleteApiConversationCallRequest.php new file mode 100644 index 000000000..2055627a6 --- /dev/null +++ b/app/Http/Requests/DeleteApiConversationCallRequest.php @@ -0,0 +1,42 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + return $user->canMessage($partner) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'failed' => ['sometimes', 'boolean'], + ]; + } + + /** + * What the user ending a call they didn't pick up did: declined it, or tried to answer and couldn't. + */ + public function unanswered(): OrgEvent + { + return $this->boolean('failed') ? OrgEvent::CallFailed : OrgEvent::CallDeclined; + } +} diff --git a/app/Http/Requests/ListApiConversationCallIceServersRequest.php b/app/Http/Requests/ListApiConversationCallIceServersRequest.php new file mode 100644 index 000000000..301969f4a --- /dev/null +++ b/app/Http/Requests/ListApiConversationCallIceServersRequest.php @@ -0,0 +1,40 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + if (! $user->canMessage($partner) || ! Conversation::between($user, $partner) instanceof Conversation) { + return Response::denyAsNotFound(); + } + + return $turn->isAvailable() ? Response::allow() : Response::deny("Calls aren't available right now."); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ShowApiConversationCallRequest.php b/app/Http/Requests/ShowApiConversationCallRequest.php new file mode 100644 index 000000000..611e1714f --- /dev/null +++ b/app/Http/Requests/ShowApiConversationCallRequest.php @@ -0,0 +1,31 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + return $user->canMessage($partner) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/UpdateApiConversationCallRequest.php b/app/Http/Requests/UpdateApiConversationCallRequest.php new file mode 100644 index 000000000..0958ed250 --- /dev/null +++ b/app/Http/Requests/UpdateApiConversationCallRequest.php @@ -0,0 +1,58 @@ +user(); + assert($user instanceof User); + + $partner = $this->route('partner'); + assert($partner instanceof User); + + if (! $user->canMessage($partner)) { + return Response::denyAsNotFound(); + } + + return ($calls->incoming($user)['partner']->id ?? null) === $partner->id + ? Response::allow() + : Response::deny('This call has ended.'); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'sdp' => ['required', 'string', 'starts_with:v=0', 'max:10000'], + ]; + } + + /** + * The call being answered. + */ + public function call(): Call + { + $user = $this->user(); + assert($user instanceof User); + + $call = resolve(CallQuery::class)->get($user); + assert($call !== null); + + return $call; + } +} diff --git a/routes/api.php b/routes/api.php index 67cc0a186..abe0aebae 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Exceptions\ApiException; +use App\Http\Controllers\Api\CallController; use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; use App\Http\Controllers\Api\CommitPinController; @@ -15,6 +16,8 @@ use App\Http\Controllers\Api\CommitStarController; use App\Http\Controllers\Api\CommitTranslationController; use App\Http\Controllers\Api\ConversationArchiveController; +use App\Http\Controllers\Api\ConversationCallController; +use App\Http\Controllers\Api\ConversationCallIceServerController; use App\Http\Controllers\Api\ConversationController; use App\Http\Controllers\Api\ConversationDropController; use App\Http\Controllers\Api\ConversationMergeController; @@ -355,6 +358,24 @@ ->middleware([PreventPostingInTimeout::class, 'throttle:pings']) ->name('api-org-ping.store'); + // Remote Calls: the app swaps WebRTC offers and answers through these as the web's calls do, so either side may + // be the web or the app... + Route::middleware('throttle:conversations')->group(function (): void { + Route::get('remotes/call', [CallController::class, 'show'])->name('api-call.show'); + Route::get('remotes/@{partner:handle}/call', [ConversationCallController::class, 'show']) + ->name('api-conversation-call.show'); + Route::put('remotes/@{partner:handle}/call', [ConversationCallController::class, 'update']) + ->name('api-conversation-call.update'); + Route::delete('remotes/@{partner:handle}/call', [ConversationCallController::class, 'destroy']) + ->name('api-conversation-call.destroy'); + }); + Route::get('remotes/@{partner:handle}/call/ice-servers', [ConversationCallIceServerController::class, 'index']) + ->middleware('throttle:messages') + ->name('api-conversation-call-ice-server.index'); + Route::post('remotes/@{partner:handle}/call', [ConversationCallController::class, 'store']) + ->middleware([PreventPostingInTimeout::class, 'throttle:messages']) + ->name('api-conversation-call.store'); + // Remote Reactions... Route::middleware('throttle:conversations')->group(function (): void { Route::get('remotes/reactions', [MessageReactionController::class, 'index'])->name('api-message-reaction.index'); diff --git a/tests/Feature/Controllers/Api/CallControllerTest.php b/tests/Feature/Controllers/Api/CallControllerTest.php new file mode 100644 index 000000000..d8a687e31 --- /dev/null +++ b/tests/Feature/Controllers/Api/CallControllerTest.php @@ -0,0 +1,38 @@ + true, 'remotes.everyone' => true]); + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->partner = User::factory()->withRemotes()->create(); + sendChatMessage($this->partner, $this->user); +}); + +it('shows the call ringing for the user, with its caller and offer', function (): void { + resolve(CreateConversationCall::class)->handle($this->partner, $this->user, "v=0\r\ns=offer\r\n"); + + signedApiRequest($this->keyPair, 'GET', route('api-call.show'))->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('call.partner.handle', $this->partner->handle) + ->assertJsonPath('call.sdp', "v=0\r\ns=offer\r\n") + ->assertJsonPath('call.is_muted', false); +}); + +it('stops ringing once the user answers', function (): void { + resolve(CreateConversationCall::class)->handle($this->partner, $this->user, "v=0\r\n"); + resolve(UpdateConversationCall::class)->handle($this->user, $this->partner, resolve(CallQuery::class)->get($this->user), "v=0\r\n"); + + signedApiRequest($this->keyPair, 'GET', route('api-call.show'))->assertOk() + ->assertExactJson(['call' => null]); +}); + +it('returns 401 for an unsigned check', function (): void { + $this->getJson(route('api-call.show'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/ConversationCallControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php new file mode 100644 index 000000000..7602919d9 --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationCallControllerTest.php @@ -0,0 +1,153 @@ + true, + 'remotes.everyone' => true, + 'services.cloudflare_turn.key_id' => 'key', + 'services.cloudflare_turn.api_token' => 'token', + ]); + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->partner = User::factory()->withRemotes()->create(); + $this->offer = "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=offer\r\n"; + $this->answer = "v=0\r\no=- 3 4 IN IP4 127.0.0.1\r\ns=answer\r\n"; +}); + +it('rings the partner with the offer, which the web picks up and answers', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertNoContent(); + + expect(resolve(CallQuery::class)->incoming($this->partner))->toMatchArray(['sdp' => $this->offer]) + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe(OrgEvent::CallStarted); + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call.show', $this->partner))->assertOk() + ->assertExactJson(['call' => ['answer' => null]]); + + $this->actingAs($this->partner)->putJson(route('conversation-call.update', $this->user), ['sdp' => $this->answer])->assertNoContent(); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call.show', $this->partner))->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['call' => ['answer' => $this->answer]]); +}); + +it('has no outgoing call before ringing', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call.show', $this->partner))->assertOk() + ->assertExactJson(['call' => null]); +}); + +it('answers a call the partner placed on the web', function (): void { + sendChatMessage($this->partner, $this->user); + $this->actingAs($this->partner)->postJson(route('conversation-call.store', $this->user), ['sdp' => $this->offer])->assertNoContent(); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-call.update', $this->partner), ['sdp' => $this->answer])->assertNoContent(); + + expect(resolve(CallQuery::class)->outgoing($this->partner, $this->user))->toBe(['answer' => $this->answer]) + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe(OrgEvent::CallAnswered); +}); + +it('does not answer a call that is not ringing', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'PUT', route('api-conversation-call.update', $this->partner), ['sdp' => $this->answer])->assertForbidden() + ->assertJsonPath('message', 'This call has ended.'); +}); + +it('logs how an unanswered call ended', function (bool $isCaller, array $data, OrgEvent $event): void { + sendChatMessage($this->partner, $this->user); + [$caller, $callee] = $isCaller ? [$this->user, $this->partner] : [$this->partner, $this->user]; + resolve(CreateConversationCall::class)->handle($caller, $callee, $this->offer); + + signedApiRequest($this->keyPair, 'DELETE', route('api-conversation-call.destroy', $this->partner), $data)->assertNoContent(); + + expect(resolve(CallQuery::class)->get($callee))->toBeNull() + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe($event); +})->with([ + 'cancelled' => [true, [], OrgEvent::CallCancelled], + 'declined' => [false, [], OrgEvent::CallDeclined], + 'failed to answer' => [false, ['failed' => true], OrgEvent::CallFailed], +]); + +it('requires a conversation before calling', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "Say hi to {$this->partner->mention()} before calling them."); + + expect(resolve(CallQuery::class)->get($this->partner))->toBeNull(); +}); + +it('does not call while calls are not available', function (): void { + sendChatMessage($this->partner, $this->user); + config(['services.cloudflare_turn.key_id' => null]); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "Calls aren't available right now."); +}); + +it('does not call while the user is on an org call', function (): void { + sendChatMessage($this->partner, $this->user); + cache()->put(CreateOrgCall::memberKey($this->user->id), fake()->uuid(), 60); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "You're already on a call."); +}); + +it('does not call a partner who cannot take it', function (Closure $busy): void { + sendChatMessage($this->partner, $this->user); + $busy($this->partner); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertForbidden() + ->assertJsonPath('message', "You can't call {$this->partner->mention()} right now."); +})->with([ + 'with Remotes disabled' => [fn (User $partner): bool => $partner->update(['remotes_enabled' => false])], + 'ringing for someone else' => [function (User $partner): void { + $caller = User::factory()->withRemotes()->create(); + sendChatMessage($caller, $partner); + resolve(CreateConversationCall::class)->handle($caller, $partner, "v=0\r\n"); + }], + 'on an org call' => [fn (User $partner): bool => cache()->put(CreateOrgCall::memberKey($partner->id), fake()->uuid(), 60)], +]); + +it('rejects an offer that is not a session description', function (): void { + sendChatMessage($this->partner, $this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => 'hello'])->assertUnprocessable() + ->assertJsonValidationErrors('sdp'); +}); + +it('hides calls with someone either person blocks', function (string $method, string $route, array $data): void { + sendChatMessage($this->partner, $this->user); + $this->partner->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, $method, route($route, $this->partner), $data)->assertNotFound(); +})->with([ + 'show' => ['GET', 'api-conversation-call.show', []], + 'store' => ['POST', 'api-conversation-call.store', ['sdp' => "v=0\r\n"]], + 'update' => ['PUT', 'api-conversation-call.update', ['sdp' => "v=0\r\n"]], + 'destroy' => ['DELETE', 'api-conversation-call.destroy', []], +]); + +it('hides calls while the user has Remotes disabled', function (): void { + sendChatMessage($this->partner, $this->user); + $this->user->update(['remotes_enabled' => false]); + + signedApiRequest($this->keyPair, 'POST', route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertNotFound(); + + expect(resolve(CallQuery::class)->get($this->partner))->toBeNull(); +}); + +it('returns 401 for an unsigned call', function (): void { + $this->postJson(route('api-conversation-call.store', $this->partner), ['sdp' => $this->offer])->assertUnauthorized(); + + expect(resolve(CallQuery::class)->get($this->partner))->toBeNull(); +}); diff --git a/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php b/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php new file mode 100644 index 000000000..c8a0e3f90 --- /dev/null +++ b/tests/Feature/Controllers/Api/ConversationCallIceServerControllerTest.php @@ -0,0 +1,44 @@ + true, + 'remotes.everyone' => true, + 'services.cloudflare_turn.key_id' => 'key', + 'services.cloudflare_turn.api_token' => 'token', + ]); + $this->user = User::factory()->withRemotes()->create(); + $this->keyPair = apiKeyPairFor($this->user); + $this->partner = User::factory()->withRemotes()->create(); +}); + +it('hands out fresh relay credentials for a call with the partner', function (): void { + sendChatMessage($this->partner, $this->user); + Http::fake(['rtc.live.cloudflare.com/*' => Http::response(['iceServers' => [ + ['urls' => ['turn:turn.cloudflare.com:3478?transport=udp'], 'username' => 'user', 'credential' => 'secret'], + ]])]); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call-ice-server.index', $this->partner))->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson([ + 'ice_servers' => [['urls' => ['turn:turn.cloudflare.com:3478?transport=udp'], 'username' => 'user', 'credential' => 'secret']], + 'is_relay_only' => true, + ]); +}); + +it('hands out nothing without a conversation', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call-ice-server.index', $this->partner))->assertNotFound(); +}); + +it('hands out nothing while calls are not available', function (): void { + sendChatMessage($this->partner, $this->user); + config(['services.cloudflare_turn.api_token' => null]); + + signedApiRequest($this->keyPair, 'GET', route('api-conversation-call-ice-server.index', $this->partner))->assertForbidden() + ->assertJsonPath('message', "Calls aren't available right now."); +});