Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/Http/Controllers/Api/CallController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Resources\UserResource;
use App\Models\User;
use App\Queries\CallQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class CallController
{
/**
* The call ringing for the user, with the caller's offer to answer, until they answer it. The app polls it as often
* as the web does, which is how a call rings while the app is open.
*/
public function show(#[CurrentUser] User $user, CallQuery $calls): JsonResponse
{
$call = $calls->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');
}
}
59 changes: 59 additions & 0 deletions app/Http/Controllers/Api/ConversationCallController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateConversationCall;
use App\Actions\DeleteConversationCall;
use App\Actions\UpdateConversationCall;
use App\Http\Requests\CreateApiConversationCallRequest;
use App\Http\Requests\DeleteApiConversationCallRequest;
use App\Http\Requests\ShowApiConversationCallRequest;
use App\Http\Requests\UpdateApiConversationCallRequest;
use App\Models\User;
use App\Queries\CallQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;

final readonly class ConversationCallController
{
/**
* The user's call to the partner while it rings, with the partner's answer once they accept, as the web polls it.
*/
public function show(ShowApiConversationCallRequest $request, #[CurrentUser] User $user, User $partner, CallQuery $calls): JsonResponse
{
return response()->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();
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/Api/ConversationCallIceServerController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Contracts\Services\TurnProvider;
use App\Http\Requests\ListApiConversationCallIceServersRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;

final readonly class ConversationCallIceServerController
{
/**
* Fresh relay credentials for one call with the partner, which the app's WebRTC connection goes through as the
* browser's does.
*/
public function index(ListApiConversationCallIceServersRequest $request, User $partner, TurnProvider $turn): JsonResponse
{
return response()->json([
'ice_servers' => $turn->iceServers(),
'is_relay_only' => $turn->isRelayOnly(),
])->header('Cache-Control', 'private, no-store');
}
}
62 changes: 62 additions & 0 deletions app/Http/Requests/CreateApiConversationCallRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Contracts\Services\TurnProvider;
use App\Enums\PushAccess;
use App\Models\Conversation;
use App\Models\User;
use App\Queries\CallQuery;
use App\Queries\OrgCallQuery;
use App\Queries\PushAccessQuery;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class CreateApiConversationCallRequest extends FormRequest
{
/**
* A call goes where a ping could: into a conversation that has started, to someone who can still be pushed to,
* and not over another call already ringing for them,
* or one of their orgs' calls they're in.
*/
public function authorize(PushAccessQuery $access, CallQuery $calls, OrgCallQuery $orgCalls, TurnProvider $turn): Response
{
$user = $this->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<string, array<int, string>>
*/
public function rules(): array
{
return [
'sdp' => ['required', 'string', 'starts_with:v=0', 'max:10000'],
];
}
}
42 changes: 42 additions & 0 deletions app/Http/Requests/DeleteApiConversationCallRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\OrgEvent;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class DeleteApiConversationCallRequest extends FormRequest
{
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$partner = $this->route('partner');
assert($partner instanceof User);

return $user->canMessage($partner) ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array<string, array<int, string>>
*/
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;
}
}
40 changes: 40 additions & 0 deletions app/Http/Requests/ListApiConversationCallIceServersRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Contracts\Services\TurnProvider;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class ListApiConversationCallIceServersRequest extends FormRequest
{
/**
* Relay credentials cost money, so they only go to someone about to call, or answer, this partner.
*/
public function authorize(TurnProvider $turn): Response
{
$user = $this->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 [];
}
}
31 changes: 31 additions & 0 deletions app/Http/Requests/ShowApiConversationCallRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class ShowApiConversationCallRequest extends FormRequest
{
public function authorize(): Response
{
$user = $this->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 [];
}
}
58 changes: 58 additions & 0 deletions app/Http/Requests/UpdateApiConversationCallRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Dto\Call;
use App\Models\User;
use App\Queries\CallQuery;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class UpdateApiConversationCallRequest extends FormRequest
{
/**
* Only a call the partner is ringing the user with can be answered, and only once.
*/
public function authorize(CallQuery $calls): Response
{
$user = $this->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<string, array<int, string>>
*/
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;
}
}
21 changes: 21 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
Loading