Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .ai/rules/auth.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
paths:
- app/Http/Controllers/Auth/GoogleBusinessController.php
- 'app/Http/Controllers/Auth/**'
---

# Auth
Expand All @@ -10,3 +11,6 @@ Google Business (like every redirect network) keeps its access and refresh token

## Finish lands on the channel, return_to only serves exits
"Finish connection" (`SocialController@finish`) always redirects to `app.channels.publish` of the connected channel (the new account, or the first one when reconnecting or refreshing) and flashes `connectedChannel` so the posting-goal dialog opens there for a new channel. The `return_to` target kept in `PendingConnection` is used only by close (X), Back, cancel and the error states (`backUrl`, "Try again"), never after a successful finish.

## Bluesky requires a confirmed email before connection
Do not complete a Bluesky connection or reconnect unless emailConfirmed is explicitly true. TryPost requires the account to be eligible for video publishing even if text posting works. Recheck the authenticated session at Finish connection so pending logins and email changes cannot bypass the requirement. Existing accounts can lose email confirmation later: preserve unconfirmed_email as an actionable publish rejection instead of retrying or treating the session token as expired.
2 changes: 1 addition & 1 deletion .ai/rules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Before planning or editing, find the row whose globs match the file's path and r
| --- | --- |
| app/Actions/Analytics/** | .ai/rules/analytics.md |
| app/** | .ai/rules/app.md |
| app/Http/Controllers/Auth/GoogleBusinessController.php | .ai/rules/auth.md |
| app/Http/Controllers/Auth/GoogleBusinessController.php, app/Http/Controllers/Auth/** | .ai/rules/auth.md |
| app/Services/Analytics/Collectors/** | .ai/rules/collectors.md |
| resources/js/components/posts/editor/** | .ai/rules/editor.md |
| app/Enums/GoogleBusiness/**, app/Jobs/PublishToSocialPlatform.php, app/Jobs/ReconcileGoogleBusinessPost.php, app/Console/Commands/ReconcileGoogleBusinessPosts.php, app/Services/Social/GoogleBusinessPublisher.php, app/Support/PostPlatformMetaRules.php | .ai/rules/google-business.md |
Expand Down
60 changes: 60 additions & 0 deletions app/Dto/BlueskySession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace App\Dto;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Validator;

final readonly class BlueskySession
{
private function __construct(
public string $did,
public string $handle,
public ?string $accessToken,
public ?string $refreshToken,
public ?bool $emailConfirmed,
) {}

public static function fromResponse(Response $response): ?self
{
$data = $response->json();

if (! $response->successful() || ! is_array($data)) {
return null;
}

$emailConfirmed = data_get($data, 'emailConfirmed');

if ($emailConfirmed !== null && ! is_bool($emailConfirmed)) {
return null;
}

$validator = Validator::make($data, [
'did' => ['required', 'string'],
'handle' => ['required', 'string'],
'accessJwt' => ['sometimes', 'required', 'string'],
'refreshJwt' => ['sometimes', 'required', 'string'],
]);

if ($validator->fails()) {
return null;
}

$data = $validator->validated();

return new self(
did: $data['did'],
handle: $data['handle'],
accessToken: data_get($data, 'accessJwt'),
refreshToken: data_get($data, 'refreshJwt'),
emailConfirmed: $emailConfirmed,
);
}

public function hasTokens(): bool
{
return $this->accessToken !== null && $this->refreshToken !== null;
}
}
16 changes: 16 additions & 0 deletions app/Exceptions/Social/BlueskyPublishException.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ public static function fromApiResponse(mixed $response): static
$error = data_get($body, 'error', '');
$errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.');

if (self::isEmailUnconfirmed($response)) {
return (new static(
userMessage: 'Confirm your email in Bluesky settings, then try publishing again.',
category: ErrorCategory::Permission,
platformErrorCode: 'unconfirmed_email',
rawResponse: $rawResponse,
))->asNetworkRejection();
}

if (self::isConfirmedDeadToken($response)) {
throw new TokenExpiredException(
message: $errorMessage,
Expand Down Expand Up @@ -75,6 +84,13 @@ public function platform(): string
return 'bluesky';
}

public static function isEmailUnconfirmed(Response $response): bool
{
$error = $response->json('jobStatus.error') ?? $response->json('error');

return $response->clientError() && $error === 'unconfirmed_email';
}

/**
* Whether this response confirms the account's own session token is dead
* (not merely a transient or content-specific failure). Shared with
Expand Down
63 changes: 49 additions & 14 deletions app/Http/Controllers/Auth/BlueskyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

namespace App\Http\Controllers\Auth;

use App\Dto\BlueskySession;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Exceptions\SocialAccount\ConnectFlowException;
use App\Models\SocialAccount;
use App\Services\Social\BlueskyLexicon;
use App\Support\Social\PendingConnection;
use Exception;
Expand Down Expand Up @@ -51,6 +54,10 @@ public function store(Request $request): RedirectResponse

$service = config('trypost.platforms.bluesky.default_service');

if (PendingConnection::current()?->platform() !== $this->platform) {
$this->rememberConnectSession($request, $workspace);
}

try {
// Authenticate with Bluesky
$response = Http::post("{$service}/xrpc/".BlueskyLexicon::CREATE_SESSION, [
Expand All @@ -67,33 +74,39 @@ public function store(Request $request): RedirectResponse
throw ValidationException::withMessages(['password' => __('accounts.bluesky.invalid_credentials')]);
}

$data = $response->json();
$session = BlueskySession::fromResponse($response);

if ($session === null || ! $session->hasTokens()) {
return $this->failConnection('error_connecting');
}

if ($session->emailConfirmed !== true) {
return $this->failConnection($session->emailConfirmed === false
? 'bluesky_email_unconfirmed'
: 'error_connecting');
}

// Get profile
$profileResponse = Http::withToken(data_get($data, 'accessJwt'))
$profileResponse = Http::withToken($session->accessToken)
->get("{$service}/xrpc/".BlueskyLexicon::GET_PROFILE, [
'actor' => data_get($data, 'did'),
'actor' => $session->did,
]);

$profile = $profileResponse->successful() ? $profileResponse->json() : [];

if (PendingConnection::current()?->platform() !== $this->platform) {
$this->rememberConnectSession($request, $workspace);
}

return $this->offerIdentities($workspace, [
PendingConnection::identity(
$this->platform,
(string) data_get($data, 'did'),
data_get($profile, 'displayName') ?: data_get($data, 'handle'),
data_get($data, 'handle'),
$session->did,
data_get($profile, 'displayName') ?: $session->handle,
$session->handle,
data_get($profile, 'avatar'),
$this->platform->identityType()->value,
[
'username' => data_get($data, 'handle'),
'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')),
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'username' => $session->handle,
'display_name' => data_get($profile, 'displayName', $session->handle),
'access_token' => $session->accessToken,
'refresh_token' => $session->refreshToken,
'token_expires_at' => now()->addHours(2),
'meta' => [
'service' => $service,
Expand All @@ -114,4 +127,26 @@ public function store(Request $request): RedirectResponse
throw ValidationException::withMessages(['password' => __('accounts.bluesky.connection_error')]);
}
}

protected function accountValues(array $identity, ?SocialAccount $reconnect): array
{
$response = Http::withToken(data_get($identity, 'attributes.access_token'))
->connectTimeout(10)
->timeout(30)
->get(config('trypost.platforms.bluesky.default_service').'/xrpc/'.BlueskyLexicon::GET_SESSION);

$session = BlueskySession::fromResponse($response);

if ($session === null || $session->did !== data_get($identity, 'platform_user_id')) {
throw new ConnectFlowException('error_connecting', $this->platform);
}

if ($session->emailConfirmed !== true) {
throw new ConnectFlowException($session->emailConfirmed === false
? 'bluesky_email_unconfirmed'
: 'error_connecting', $this->platform);
}

return parent::accountValues($identity, $reconnect);
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/Auth/SocialController.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public function finish(FinishSocialConnectionRequest $request): RedirectResponse

try {
$accounts = $this->connectIdentities($workspace, $pending->selected($request->validated('identities')), $reconnect);
} catch (NetworkAlreadyConnectedException $e) {
} catch (ConnectFlowException|NetworkAlreadyConnectedException $e) {
return $this->failConnection($e->messageKey);
} catch (Exception $e) {
Log::error('Social connect error', [
Expand Down
2 changes: 2 additions & 0 deletions app/Services/Social/BlueskyLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ final class BlueskyLexicon

public const CREATE_SESSION = 'com.atproto.server.createSession';

public const GET_SESSION = 'com.atproto.server.getSession';

public const REFRESH_SESSION = 'com.atproto.server.refreshSession';

public const GET_SERVICE_AUTH = 'com.atproto.server.getServiceAuth';
Expand Down
12 changes: 10 additions & 2 deletions app/Services/Social/BlueskyPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -444,8 +444,10 @@ private function downloadToTempFile(string $url, string $prefix, int $timeoutSec
* 4. poll app.bsky.video.getJobStatus until the blob is ready,
* retrying the whole upload a few times on a transient transcode failure.
*
* Returns null on failure so mediaEmbed stops publication. Only a link
* preview thumbnail may be omitted when its upload fails.
* Returns null on other failures so mediaEmbed stops publication. Only a
* link preview thumbnail may be omitted when its upload fails.
*
* @throws BlueskyPublishException when the account email is unconfirmed.
*/
private function uploadVideo(SocialAccount $account, string $service, string $url, ?string $mimeType): ?array
{
Expand Down Expand Up @@ -510,6 +512,8 @@ private function uploadVideo(SocialAccount $account, string $service, string $ur
}

return null;
} catch (BlueskyPublishException $e) {
throw $e;
} catch (Throwable $e) {
Log::error('Bluesky video upload exception', [
'error' => $e->getMessage(),
Expand Down Expand Up @@ -554,6 +558,10 @@ private function attemptVideoUpload(SocialAccount $account, string $uploadToken,
// A re-upload of identical bytes returns 409 with the already-finished
// job, whose blob we can embed directly.
if ($response->failed() && $response->status() !== 409) {
if (BlueskyPublishException::isEmailUnconfirmed($response)) {
throw BlueskyPublishException::fromApiResponse($response);
}

Log::error('Bluesky video upload failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
Expand Down
37 changes: 21 additions & 16 deletions app/Services/Social/ConnectionVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Services\Social;

use App\Actions\SocialAccount\SyncXSubscription;
use App\Dto\BlueskySession;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\BlueskyPublishException;
Expand All @@ -24,6 +25,7 @@
use App\Services\Social\Telegram\TelegramApi;
use App\Support\GoogleBusinessResourceName;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

Expand Down Expand Up @@ -341,14 +343,7 @@ private function refreshBlueskyToken(SocialAccount $account): void
$response = $client->send(fn () => $this->refreshHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/".BlueskyLexicon::REFRESH_SESSION));

$data = $response->json();
$account->update([
'access_token' => $this->tokenFrom($data, $account->platform, 'accessJwt'),
'refresh_token' => $this->tokenFrom($data, $account->platform, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);

$account->refresh();
$this->updateBlueskySession($account, $response);

return;
} catch (TokenExpiredException) {
Expand All @@ -362,14 +357,7 @@ private function refreshBlueskyToken(SocialAccount $account): void
'password' => decrypt($account->meta['password']),
]));

$data = $reauth->json();
$account->update([
'access_token' => $this->tokenFrom($data, $account->platform, 'accessJwt'),
'refresh_token' => $this->tokenFrom($data, $account->platform, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);

$account->refresh();
$this->updateBlueskySession($account, $reauth);

return;
} catch (TokenExpiredException) {
Expand All @@ -380,6 +368,23 @@ private function refreshBlueskyToken(SocialAccount $account): void
throw new TokenExpiredException('Bluesky session expired');
}

private function updateBlueskySession(SocialAccount $account, Response $response): void
{
$session = BlueskySession::fromResponse($response);

if ($session === null || ! $session->hasTokens() || $session->did !== $account->platform_user_id) {
throw new PlatformUnavailableException('Bluesky returned an invalid session response.');
}

$account->update([
'access_token' => $session->accessToken,
'refresh_token' => $session->refreshToken,
'token_expires_at' => now()->addHours(2),
]);

$account->refresh();
}

private function refreshYouTubeToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
Expand Down
1 change: 1 addition & 0 deletions lang/ar/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
'start_again' => 'ابدأ من جديد',
],
'errors' => [
'bluesky_email_unconfirmed' => 'أكّد بريدك الإلكتروني في إعدادات Bluesky، ثم اربط حسابك مجددًا. يتطلب TryPost بريدًا إلكترونيًا مؤكدًا لنشر الفيديوهات.',
'error_connecting' => 'خطأ في ربط الحساب. يرجى المحاولة مرة أخرى.',
'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على حساب لهذه الشبكة. افصله أولًا.',
'wrong_account' => 'هذا حساب مختلف. صرّح بالحساب الذي تعيد ربطه.',
Expand Down
1 change: 1 addition & 0 deletions lang/de/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
'start_again' => 'Neu starten',
],
'errors' => [
'bluesky_email_unconfirmed' => 'Bestätige deine E-Mail-Adresse in den Bluesky-Einstellungen und verbinde dein Konto erneut. TryPost benötigt eine bestätigte E-Mail-Adresse, um Videos zu veröffentlichen.',
'error_connecting' => 'Fehler beim Verbinden des Kontos. Bitte versuche es erneut.',
'network_taken' => 'Dieser Workspace hat bereits ein Konto für dieses Netzwerk. Trenne es zuerst.',
'wrong_account' => 'Das ist ein anderes Konto. Autorisiere das Konto, das du neu verbindest.',
Expand Down
1 change: 1 addition & 0 deletions lang/el/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
'start_again' => 'Από την αρχή',
],
'errors' => [
'bluesky_email_unconfirmed' => 'Επιβεβαιώστε το email σας στις ρυθμίσεις του Bluesky και συνδέστε ξανά τον λογαριασμό σας. Το TryPost απαιτεί επιβεβαιωμένο email για τη δημοσίευση βίντεο.',
'error_connecting' => 'Σφάλμα κατά τη σύνδεση του λογαριασμού. Παρακαλούμε δοκιμάστε ξανά.',
'network_taken' => 'Αυτό το workspace έχει ήδη λογαριασμό για αυτό το δίκτυο. Αποσυνδέστε τον πρώτα.',
'wrong_account' => 'Αυτός είναι διαφορετικός λογαριασμός. Εξουσιοδοτήστε αυτόν που επανασυνδέετε.',
Expand Down
1 change: 1 addition & 0 deletions lang/en/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
'start_again' => 'Start again',
],
'errors' => [
'bluesky_email_unconfirmed' => 'Confirm your email in Bluesky settings, then connect your account again. TryPost requires a confirmed email to publish videos.',
'error_connecting' => 'Error connecting account. Please try again.',
'network_taken' => 'This workspace already has an account for this network. Disconnect it first.',
'wrong_account' => 'That is a different account. Authorize the one you are reconnecting.',
Expand Down
1 change: 1 addition & 0 deletions lang/es/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
'start_again' => 'Empezar de nuevo',
],
'errors' => [
'bluesky_email_unconfirmed' => 'Confirma tu correo en los ajustes de Bluesky y vuelve a conectar tu cuenta. TryPost requiere un correo confirmado para publicar vídeos.',
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',
'network_taken' => 'Este workspace ya tiene una cuenta para esta red. Desconéctala primero.',
'wrong_account' => 'Esa es una cuenta diferente. Autoriza la que estás reconectando.',
Expand Down
1 change: 1 addition & 0 deletions lang/fr/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
'start_again' => 'Recommencer',
],
'errors' => [
'bluesky_email_unconfirmed' => 'Confirmez votre adresse e-mail dans les paramètres de Bluesky, puis reconnectez votre compte. TryPost exige une adresse confirmée pour publier des vidéos.',
'error_connecting' => 'Erreur lors de la connexion du compte. Veuillez réessayer.',
'network_taken' => 'Cet espace de travail a déjà un compte pour ce réseau. Déconnectez-le d\'abord.',
'wrong_account' => 'C\'est un autre compte. Autorisez celui que vous reconnectez.',
Expand Down
1 change: 1 addition & 0 deletions lang/it/accounts.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
'start_again' => 'Ricomincia',
],
'errors' => [
'bluesky_email_unconfirmed' => 'Conferma la tua email nelle impostazioni di Bluesky, poi collega di nuovo il tuo account. TryPost richiede un’email confermata per pubblicare video.',
'error_connecting' => 'Errore durante il collegamento dell\'account. Riprova.',
'network_taken' => 'Questo workspace ha già un account per questa rete. Scollegalo prima.',
'wrong_account' => 'Questo è un account diverso. Autorizza quello che stai ricollegando.',
Expand Down
Loading
Loading