2026-01-25 20:28:01 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-25 20:28:01 +00:00
|
|
|
namespace App\Services\Social;
|
|
|
|
|
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
2026-05-19 11:16:43 +00:00
|
|
|
use App\Exceptions\PlatformUnavailableException;
|
2026-01-25 20:28:01 +00:00
|
|
|
use App\Exceptions\TokenExpiredException;
|
|
|
|
|
use App\Models\SocialAccount;
|
2026-04-01 15:02:15 +00:00
|
|
|
use Illuminate\Support\Facades\Cache;
|
2026-01-25 20:28:01 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
class ConnectionVerifier
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Verify that a social account connection is still valid.
|
|
|
|
|
*
|
|
|
|
|
* @throws TokenExpiredException if the connection is invalid
|
2026-05-19 11:16:43 +00:00
|
|
|
* @throws PlatformUnavailableException if the platform's API is down
|
2026-01-25 20:28:01 +00:00
|
|
|
*/
|
|
|
|
|
public function verify(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-05-04 00:58:25 +00:00
|
|
|
// Hard-expired tokens cannot make API calls — refresh is mandatory.
|
|
|
|
|
// For tokens that are still valid OR only "expiring soon", try the
|
|
|
|
|
// verify endpoint FIRST with the current access_token. This avoids
|
|
|
|
|
// rotating refresh_tokens unnecessarily — many providers (X v2,
|
|
|
|
|
// LinkedIn, etc.) invalidate the previous refresh_token on each
|
|
|
|
|
// refresh, so proactive refreshes during races cause false-positive
|
|
|
|
|
// disconnects even though the access_token still works fine.
|
|
|
|
|
if ($account->is_token_expired) {
|
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
|
|
|
$this->refreshToken($account);
|
2026-05-04 00:58:25 +00:00
|
|
|
|
|
|
|
|
return $this->callVerifyEndpoint($account);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return $this->callVerifyEndpoint($account);
|
|
|
|
|
} catch (TokenExpiredException $e) {
|
|
|
|
|
// Verify returned 401: the access_token is actually invalid.
|
|
|
|
|
// Refresh and retry once with the new token.
|
|
|
|
|
try {
|
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
|
|
|
$this->refreshToken($account);
|
2026-05-04 00:58:25 +00:00
|
|
|
} catch (TokenExpiredException) {
|
|
|
|
|
throw $e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->callVerifyEndpoint($account);
|
2026-01-26 13:06:54 +00:00
|
|
|
}
|
2026-05-04 00:58:25 +00:00
|
|
|
}
|
2026-01-26 13:06:54 +00:00
|
|
|
|
2026-05-04 00:58:25 +00:00
|
|
|
/**
|
|
|
|
|
* @throws TokenExpiredException
|
|
|
|
|
*/
|
|
|
|
|
private function callVerifyEndpoint(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-01-25 20:28:01 +00:00
|
|
|
return match ($account->platform) {
|
|
|
|
|
Platform::LinkedIn => $this->verifyLinkedIn($account),
|
|
|
|
|
Platform::LinkedInPage => $this->verifyLinkedInPage($account),
|
|
|
|
|
Platform::X => $this->verifyX($account),
|
2026-04-02 20:57:06 +00:00
|
|
|
Platform::Instagram, Platform::InstagramFacebook => $this->verifyInstagram($account),
|
2026-01-25 20:28:01 +00:00
|
|
|
Platform::Facebook => $this->verifyFacebook($account),
|
|
|
|
|
Platform::Threads => $this->verifyThreads($account),
|
|
|
|
|
Platform::TikTok => $this->verifyTikTok($account),
|
|
|
|
|
Platform::YouTube => $this->verifyYouTube($account),
|
|
|
|
|
Platform::Pinterest => $this->verifyPinterest($account),
|
|
|
|
|
Platform::Bluesky => $this->verifyBluesky($account),
|
|
|
|
|
Platform::Mastodon => $this->verifyMastodon($account),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 13:06:54 +00:00
|
|
|
/**
|
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
|
|
|
* Refresh the account's token via the platform-specific OAuth flow.
|
|
|
|
|
* Callers that want the smart "try access_token first" behavior should
|
|
|
|
|
* use verify() instead. This method always attempts a refresh under
|
2026-05-19 11:27:46 +00:00
|
|
|
* the per-account lock.
|
2026-01-26 13:06:54 +00:00
|
|
|
*
|
2026-05-19 11:27:46 +00:00
|
|
|
* @throws TokenExpiredException if refresh is rejected by the provider (4xx)
|
|
|
|
|
* @throws PlatformUnavailableException if the platform is unreachable (5xx / network)
|
2026-01-26 13:06:54 +00:00
|
|
|
*/
|
fix(social): proactive token refresh actually refreshes (not just verifies)
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
|
|
|
public function refreshToken(SocialAccount $account): void
|
2026-01-26 13:06:54 +00:00
|
|
|
{
|
2026-04-01 15:02:15 +00:00
|
|
|
$lock = Cache::lock("token_refresh:{$account->id}", 30);
|
|
|
|
|
|
|
|
|
|
if (! $lock->get()) {
|
|
|
|
|
// Another process is already refreshing this token
|
|
|
|
|
$account->refresh();
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
match ($account->platform) {
|
|
|
|
|
Platform::LinkedIn, Platform::LinkedInPage => $this->refreshLinkedInToken($account),
|
|
|
|
|
Platform::X => $this->refreshXToken($account),
|
|
|
|
|
Platform::Bluesky => $this->refreshBlueskyToken($account),
|
|
|
|
|
Platform::YouTube => $this->refreshYouTubeToken($account),
|
|
|
|
|
Platform::TikTok => $this->refreshTikTokToken($account),
|
|
|
|
|
Platform::Pinterest => $this->refreshPinterestToken($account),
|
|
|
|
|
Platform::Threads => $this->refreshThreadsToken($account),
|
|
|
|
|
Platform::Instagram => $this->refreshInstagramToken($account),
|
2026-05-19 12:52:49 +00:00
|
|
|
// Facebook / InstagramFacebook use Page tokens that don't expire.
|
|
|
|
|
// Mastodon tokens don't expire either.
|
2026-04-01 15:02:15 +00:00
|
|
|
default => null,
|
|
|
|
|
};
|
|
|
|
|
} finally {
|
|
|
|
|
$lock->release();
|
|
|
|
|
}
|
2026-01-26 13:06:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshLinkedInToken(SocialAccount $account): void
|
|
|
|
|
{
|
|
|
|
|
if (! $account->refresh_token) {
|
2026-05-19 12:18:58 +00:00
|
|
|
throw new TokenExpiredException("No refresh token available for {$account->platform->label()} account");
|
2026-01-26 13:06:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 12:18:58 +00:00
|
|
|
$response = TokenRefreshClient::for($account->platform)->send(fn () => Http::asForm()
|
2026-05-19 11:16:43 +00:00
|
|
|
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
|
|
|
|
|
'grant_type' => 'refresh_token',
|
|
|
|
|
'refresh_token' => $account->refresh_token,
|
|
|
|
|
'client_id' => config('services.linkedin.client_id'),
|
|
|
|
|
'client_secret' => config('services.linkedin.client_secret'),
|
|
|
|
|
]));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
|
|
|
|
|
$account->update([
|
2026-03-31 03:40:18 +00:00
|
|
|
'access_token' => data_get($data, 'access_token'),
|
|
|
|
|
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
|
|
|
|
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
2026-01-26 14:15:29 +00:00
|
|
|
|
|
|
|
|
// Sync tokens between LinkedIn personal and LinkedIn Page
|
|
|
|
|
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
|
2026-01-26 13:06:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshXToken(SocialAccount $account): void
|
|
|
|
|
{
|
|
|
|
|
if (! $account->refresh_token) {
|
|
|
|
|
throw new TokenExpiredException('No refresh token available for X account');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:16:43 +00:00
|
|
|
$response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm()
|
2026-01-26 13:06:54 +00:00
|
|
|
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
|
2026-05-02 16:49:20 +00:00
|
|
|
->post(config('trypost.platforms.x.api').'/oauth2/token', [
|
2026-01-26 13:06:54 +00:00
|
|
|
'grant_type' => 'refresh_token',
|
|
|
|
|
'refresh_token' => $account->refresh_token,
|
2026-05-19 11:16:43 +00:00
|
|
|
]));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
|
|
|
|
|
$account->update([
|
2026-03-31 03:40:18 +00:00
|
|
|
'access_token' => data_get($data, 'access_token'),
|
|
|
|
|
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
|
|
|
|
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshBlueskyToken(SocialAccount $account): void
|
|
|
|
|
{
|
2026-05-19 11:16:43 +00:00
|
|
|
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
|
|
|
|
$client = TokenRefreshClient::for(Platform::Bluesky);
|
2026-01-26 13:06:54 +00:00
|
|
|
|
2026-05-19 11:16:43 +00:00
|
|
|
try {
|
|
|
|
|
$response = $client->send(fn () => Http::withToken($account->refresh_token)
|
|
|
|
|
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
$account->update([
|
2026-03-31 03:40:18 +00:00
|
|
|
'access_token' => data_get($data, 'accessJwt'),
|
|
|
|
|
'refresh_token' => data_get($data, 'refreshJwt'),
|
2026-01-26 13:06:54 +00:00
|
|
|
'token_expires_at' => now()->addHours(2),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
|
|
|
|
|
return;
|
2026-05-19 11:16:43 +00:00
|
|
|
} catch (TokenExpiredException) {
|
|
|
|
|
// refresh token was rejected (4xx) — fall back to re-auth below
|
2026-01-26 13:06:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isset($account->meta['password'])) {
|
|
|
|
|
try {
|
2026-05-19 11:16:43 +00:00
|
|
|
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
|
|
|
|
|
'identifier' => $account->meta['identifier'],
|
|
|
|
|
'password' => decrypt($account->meta['password']),
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$data = $reauth->json();
|
|
|
|
|
$account->update([
|
|
|
|
|
'access_token' => data_get($data, 'accessJwt'),
|
|
|
|
|
'refresh_token' => data_get($data, 'refreshJwt'),
|
|
|
|
|
'token_expires_at' => now()->addHours(2),
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
2026-05-19 11:16:43 +00:00
|
|
|
$account->refresh();
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
} catch (TokenExpiredException) {
|
|
|
|
|
// re-auth rejected with stored credentials — fall through
|
2026-01-26 13:06:54 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new TokenExpiredException('Bluesky session expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshYouTubeToken(SocialAccount $account): void
|
|
|
|
|
{
|
|
|
|
|
if (! $account->refresh_token) {
|
|
|
|
|
throw new TokenExpiredException('No refresh token available for YouTube account');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:16:43 +00:00
|
|
|
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
|
|
|
|
|
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
|
|
|
|
|
'grant_type' => 'refresh_token',
|
|
|
|
|
'refresh_token' => $account->refresh_token,
|
|
|
|
|
'client_id' => config('services.google.client_id'),
|
|
|
|
|
'client_secret' => config('services.google.client_secret'),
|
|
|
|
|
]));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
|
|
|
|
|
$account->update([
|
2026-03-31 03:40:18 +00:00
|
|
|
'access_token' => data_get($data, 'access_token'),
|
|
|
|
|
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshTikTokToken(SocialAccount $account): void
|
|
|
|
|
{
|
|
|
|
|
if (! $account->refresh_token) {
|
|
|
|
|
throw new TokenExpiredException('No refresh token available for TikTok account');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:16:43 +00:00
|
|
|
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
|
|
|
|
|
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
|
|
|
|
|
'grant_type' => 'refresh_token',
|
|
|
|
|
'refresh_token' => $account->refresh_token,
|
|
|
|
|
'client_key' => config('services.tiktok.client_id'),
|
|
|
|
|
'client_secret' => config('services.tiktok.client_secret'),
|
|
|
|
|
]));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
|
|
|
|
|
$account->update([
|
2026-03-31 03:40:18 +00:00
|
|
|
'access_token' => data_get($data, 'access_token'),
|
|
|
|
|
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
|
|
|
|
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshPinterestToken(SocialAccount $account): void
|
|
|
|
|
{
|
|
|
|
|
if (! $account->refresh_token) {
|
|
|
|
|
throw new TokenExpiredException('No refresh token available for Pinterest account');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret'));
|
|
|
|
|
|
2026-05-19 11:16:43 +00:00
|
|
|
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([
|
2026-01-26 13:06:54 +00:00
|
|
|
'Authorization' => "Basic {$credentials}",
|
|
|
|
|
'Content-Type' => 'application/x-www-form-urlencoded',
|
2026-05-02 16:49:20 +00:00
|
|
|
])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
|
2026-01-26 13:06:54 +00:00
|
|
|
'grant_type' => 'refresh_token',
|
|
|
|
|
'refresh_token' => $account->refresh_token,
|
2026-05-19 11:16:43 +00:00
|
|
|
]));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
|
|
|
|
|
$account->update([
|
2026-03-31 03:40:18 +00:00
|
|
|
'access_token' => data_get($data, 'access_token'),
|
|
|
|
|
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
|
|
|
|
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshThreadsToken(SocialAccount $account): void
|
|
|
|
|
{
|
|
|
|
|
// Threads uses long-lived tokens that can be refreshed
|
2026-05-19 11:16:43 +00:00
|
|
|
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
|
2026-01-26 13:06:54 +00:00
|
|
|
'grant_type' => 'th_refresh_token',
|
|
|
|
|
'access_token' => $account->access_token,
|
2026-05-19 11:16:43 +00:00
|
|
|
]));
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
$newToken = data_get($data, 'access_token');
|
2026-01-26 13:06:54 +00:00
|
|
|
|
|
|
|
|
$account->update([
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
'access_token' => $newToken,
|
|
|
|
|
'refresh_token' => $newToken,
|
|
|
|
|
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function refreshInstagramToken(SocialAccount $account): void
|
|
|
|
|
{
|
2026-05-19 11:16:43 +00:00
|
|
|
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
'grant_type' => 'ig_refresh_token',
|
|
|
|
|
'access_token' => $account->access_token,
|
2026-05-19 11:16:43 +00:00
|
|
|
]));
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
|
|
|
|
|
$data = $response->json();
|
|
|
|
|
$newToken = data_get($data, 'access_token');
|
|
|
|
|
|
|
|
|
|
$account->update([
|
|
|
|
|
'access_token' => $newToken,
|
|
|
|
|
'refresh_token' => $newToken,
|
2026-03-31 03:40:18 +00:00
|
|
|
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
2026-01-26 13:06:54 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$account->refresh();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 20:28:01 +00:00
|
|
|
private function verifyLinkedIn(SocialAccount $account): bool
|
|
|
|
|
{
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
|
|
|
|
->withHeaders([
|
|
|
|
|
'X-Restli-Protocol-Version' => '2.0.0',
|
|
|
|
|
'LinkedIn-Version' => '202601',
|
|
|
|
|
])
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.linkedin.api').'/rest/userinfo');
|
2026-01-25 20:28:01 +00:00
|
|
|
|
|
|
|
|
if ($response->status() === 401) {
|
|
|
|
|
throw new TokenExpiredException('LinkedIn access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyLinkedInPage(SocialAccount $account): bool
|
|
|
|
|
{
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
|
|
|
|
->withHeaders([
|
|
|
|
|
'X-Restli-Protocol-Version' => '2.0.0',
|
|
|
|
|
'LinkedIn-Version' => '202601',
|
|
|
|
|
])
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.linkedin-page.api').'/rest/organizationAcls', [
|
2026-01-25 20:28:01 +00:00
|
|
|
'q' => 'roleAssignee',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($response->status() === 401) {
|
|
|
|
|
throw new TokenExpiredException('LinkedIn Page access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyX(SocialAccount $account): bool
|
|
|
|
|
{
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.x.api').'/users/me');
|
2026-01-25 20:28:01 +00:00
|
|
|
|
|
|
|
|
if ($response->status() === 401) {
|
|
|
|
|
throw new TokenExpiredException('X access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyInstagram(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-05-04 00:58:25 +00:00
|
|
|
// Basic Instagram tokens hit graph.instagram.com; Instagram via
|
|
|
|
|
// Facebook Business uses a Facebook Page token, which only validates
|
|
|
|
|
// against graph.facebook.com — using the wrong endpoint produces a
|
|
|
|
|
// false-positive "token expired".
|
|
|
|
|
$baseUrl = $account->platform->instagramGraphBaseUrl();
|
|
|
|
|
|
|
|
|
|
$response = Http::get("{$baseUrl}/me", [
|
2026-01-25 20:28:01 +00:00
|
|
|
'fields' => 'id,username',
|
|
|
|
|
'access_token' => $account->access_token,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$body = $response->json() ?? [];
|
|
|
|
|
|
|
|
|
|
if (isset($body['error'])) {
|
|
|
|
|
$errorCode = $body['error']['code'] ?? null;
|
|
|
|
|
$errorType = $body['error']['type'] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
|
|
|
|
throw new TokenExpiredException('Instagram access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyFacebook(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-05-02 16:49:20 +00:00
|
|
|
$response = Http::get(config('trypost.platforms.facebook.graph_api').'/me', [
|
2026-01-25 20:28:01 +00:00
|
|
|
'fields' => 'id,name',
|
|
|
|
|
'access_token' => $account->access_token,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$body = $response->json() ?? [];
|
|
|
|
|
|
|
|
|
|
if (isset($body['error'])) {
|
|
|
|
|
$errorCode = $body['error']['code'] ?? null;
|
|
|
|
|
$errorType = $body['error']['type'] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
|
|
|
|
throw new TokenExpiredException('Facebook access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyThreads(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-05-02 16:49:20 +00:00
|
|
|
$response = Http::get(config('trypost.platforms.threads.graph_api').'/me', [
|
2026-01-25 20:28:01 +00:00
|
|
|
'fields' => 'id,username',
|
|
|
|
|
'access_token' => $account->access_token,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$body = $response->json() ?? [];
|
|
|
|
|
|
|
|
|
|
if (isset($body['error'])) {
|
|
|
|
|
$errorCode = $body['error']['code'] ?? null;
|
|
|
|
|
$errorType = $body['error']['type'] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
|
|
|
|
throw new TokenExpiredException('Threads access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyTikTok(SocialAccount $account): bool
|
|
|
|
|
{
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
|
|
|
|
->withHeaders([
|
|
|
|
|
'Content-Type' => 'application/json',
|
|
|
|
|
])
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.tiktok.api').'/user/info/', [
|
2026-01-25 20:28:01 +00:00
|
|
|
'fields' => 'open_id,display_name',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$body = $response->json() ?? [];
|
|
|
|
|
$errorCode = $body['error']['code'] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($response->status() === 401 || in_array($errorCode, ['access_token_invalid', 'access_token_expired', 10001, 10002])) {
|
|
|
|
|
throw new TokenExpiredException('TikTok access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyYouTube(SocialAccount $account): bool
|
|
|
|
|
{
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.youtube.data_api').'/channels', [
|
2026-01-25 20:28:01 +00:00
|
|
|
'part' => 'id',
|
|
|
|
|
'mine' => 'true',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($response->status() === 401) {
|
|
|
|
|
throw new TokenExpiredException('YouTube access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyPinterest(SocialAccount $account): bool
|
|
|
|
|
{
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
2026-05-02 16:49:20 +00:00
|
|
|
->get(config('trypost.platforms.pinterest.api').'/user_account');
|
2026-01-25 20:28:01 +00:00
|
|
|
|
|
|
|
|
if ($response->status() === 401) {
|
|
|
|
|
throw new TokenExpiredException('Pinterest access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyBluesky(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-05-19 11:16:43 +00:00
|
|
|
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
2026-01-25 20:28:01 +00:00
|
|
|
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
|
|
|
|
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
|
|
|
|
|
'actor' => $account->platform_user_id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$body = $response->json() ?? [];
|
|
|
|
|
$error = $body['error'] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($error === 'ExpiredToken' || $error === 'InvalidToken') {
|
|
|
|
|
throw new TokenExpiredException('Bluesky access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function verifyMastodon(SocialAccount $account): bool
|
|
|
|
|
{
|
2026-05-19 11:27:46 +00:00
|
|
|
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
|
2026-01-25 20:28:01 +00:00
|
|
|
|
|
|
|
|
$response = Http::withToken($account->access_token)
|
|
|
|
|
->get("{$instance}/api/v1/accounts/verify_credentials");
|
|
|
|
|
|
|
|
|
|
if ($response->status() === 401 || $response->status() === 403) {
|
|
|
|
|
throw new TokenExpiredException('Mastodon access token is invalid or expired');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $response->successful();
|
|
|
|
|
}
|
|
|
|
|
}
|