- 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
205 lines
7.9 KiB
PHP
205 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Auth;
|
|
|
|
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
|
use App\Enums\SocialAccount\Status;
|
|
use App\Models\Workspace;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\View\View;
|
|
use Inertia\Inertia;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class ThreadsController extends SocialController
|
|
{
|
|
protected SocialPlatform $platform = SocialPlatform::Threads;
|
|
|
|
protected array $scopes = [
|
|
'threads_basic',
|
|
'threads_content_publish',
|
|
];
|
|
|
|
public function connect(Request $request): Response|RedirectResponse
|
|
{
|
|
$this->ensurePlatformEnabled();
|
|
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
if (! $workspace) {
|
|
return redirect()->route('app.workspaces.create');
|
|
}
|
|
|
|
$this->authorize('manageAccounts', $workspace);
|
|
|
|
$existingAccount = $workspace->socialAccounts()
|
|
->where('platform', $this->platform->value)
|
|
->first();
|
|
|
|
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
|
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
|
session()->flash('flash.bannerStyle', 'danger');
|
|
|
|
return back();
|
|
}
|
|
|
|
session([
|
|
'social_connect_workspace' => $workspace->id,
|
|
'social_reconnect_id' => $existingAccount?->id,
|
|
]);
|
|
|
|
$state = bin2hex(random_bytes(16));
|
|
session(['threads_oauth_state' => $state]);
|
|
|
|
$params = http_build_query([
|
|
'client_id' => config('services.threads.client_id'),
|
|
'redirect_uri' => config('services.threads.redirect'),
|
|
'scope' => implode(',', $this->scopes),
|
|
'response_type' => 'code',
|
|
'state' => $state,
|
|
]);
|
|
|
|
return Inertia::location("https://threads.net/oauth/authorize?{$params}");
|
|
}
|
|
|
|
public function callback(Request $request): View
|
|
{
|
|
$workspaceId = session('social_connect_workspace');
|
|
$savedState = session('threads_oauth_state');
|
|
|
|
if (! $workspaceId) {
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value);
|
|
}
|
|
|
|
if ($request->state !== $savedState) {
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value);
|
|
}
|
|
|
|
$workspace = Workspace::find($workspaceId);
|
|
|
|
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
|
}
|
|
|
|
$reconnectId = session('social_reconnect_id');
|
|
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
|
|
|
|
// If account exists and is connected, don't allow duplicate
|
|
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
|
}
|
|
|
|
try {
|
|
// Exchange code for short-lived token
|
|
$tokenResponse = Http::asForm()->post('https://graph.threads.net/oauth/access_token', [
|
|
'client_id' => config('services.threads.client_id'),
|
|
'client_secret' => config('services.threads.client_secret'),
|
|
'grant_type' => 'authorization_code',
|
|
'redirect_uri' => config('services.threads.redirect'),
|
|
'code' => $request->code,
|
|
]);
|
|
|
|
if ($tokenResponse->failed()) {
|
|
Log::error('Threads token exchange failed', [
|
|
'status' => $tokenResponse->status(),
|
|
'body' => $tokenResponse->body(),
|
|
]);
|
|
throw new \Exception('Failed to exchange token');
|
|
}
|
|
|
|
$tokenData = $tokenResponse->json();
|
|
$shortLivedToken = $tokenData['access_token'];
|
|
$userId = $tokenData['user_id'];
|
|
|
|
// Exchange for long-lived token
|
|
$longLivedResponse = Http::get('https://graph.threads.net/access_token', [
|
|
'grant_type' => 'th_exchange_token',
|
|
'client_secret' => config('services.threads.client_secret'),
|
|
'access_token' => $shortLivedToken,
|
|
]);
|
|
|
|
$longLivedToken = $shortLivedToken;
|
|
$expiresIn = null;
|
|
|
|
if ($longLivedResponse->successful()) {
|
|
$longLivedData = $longLivedResponse->json();
|
|
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
|
|
$expiresIn = $longLivedData['expires_in'] ?? null;
|
|
}
|
|
|
|
// Fetch user profile
|
|
$profileResponse = Http::get("https://graph.threads.net/v1.0/{$userId}", [
|
|
'access_token' => $longLivedToken,
|
|
'fields' => 'id,username,name,threads_profile_picture_url',
|
|
]);
|
|
|
|
if ($profileResponse->failed()) {
|
|
Log::error('Threads profile fetch failed', [
|
|
'body' => $profileResponse->body(),
|
|
]);
|
|
throw new \Exception('Failed to fetch profile');
|
|
}
|
|
|
|
$profile = $profileResponse->json();
|
|
$avatarPath = uploadFromUrl(data_get($profile, 'threads_profile_picture_url', null));
|
|
|
|
if ($existingAccount) {
|
|
// Reconnect existing account
|
|
$existingAccount->update([
|
|
'platform_user_id' => data_get($profile, 'id'),
|
|
'username' => data_get($profile, 'username'),
|
|
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
|
|
'avatar_url' => $avatarPath,
|
|
'access_token' => $longLivedToken,
|
|
'refresh_token' => $longLivedToken,
|
|
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
|
'scopes' => $this->scopes,
|
|
]);
|
|
$existingAccount->markAsConnected();
|
|
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(true, 'Threads account reconnected!', $this->platform->value);
|
|
}
|
|
|
|
// Create new account
|
|
$workspace->socialAccounts()->create([
|
|
'platform' => $this->platform->value,
|
|
'platform_user_id' => data_get($profile, 'id'),
|
|
'username' => data_get($profile, 'username'),
|
|
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
|
|
'avatar_url' => $avatarPath,
|
|
'access_token' => $longLivedToken,
|
|
'refresh_token' => null,
|
|
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
|
'scopes' => $this->scopes,
|
|
'status' => Status::Connected,
|
|
]);
|
|
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(true, 'Threads account connected!', $this->platform->value);
|
|
} catch (\Exception $e) {
|
|
Log::error('Threads OAuth Error', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
|
|
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
|
|
|
return $this->popupCallback(false, 'Error connecting account. Please try again.', $this->platform->value);
|
|
}
|
|
}
|
|
}
|