trypost/app/Http/Controllers/Auth/LinkedInController.php
Paulo Castellano d84666360a refactor(linkedin): infer post format from media + unify account connection
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.

Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
2026-06-24 21:05:09 -03:00

372 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\LinkedInIdentityType;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
class LinkedInController extends SocialController
{
protected string $driver = 'linkedin-openid';
protected SocialPlatform $platform = SocialPlatform::LinkedIn;
/**
* The LinkedIn card stands for both the personal profile and company-page
* capabilities, each independently toggleable so self-hosters can run with
* just one. The connect flow is available while either is enabled.
*/
protected function ensurePlatformEnabled(): void
{
if (! $this->personEnabled() && ! $this->organizationEnabled()) {
abort(Response::HTTP_FORBIDDEN, 'This platform is currently unavailable.');
}
}
public function connect(Request $request): Response|View
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
$this->authorize('manageAccounts', $workspace);
session(['social_connect_workspace' => $workspace->id]);
return Inertia::location(
Socialite::driver($this->driver)
->scopes($this->connectScopes())
->redirect()
->getTargetUrl()
);
}
public function callback(Request $request): View|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($workspaceId);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
try {
$socialUser = Socialite::driver($this->driver)->user();
session([
'linkedin_pending' => [
'workspace_id' => $workspace->id,
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'expires_in' => $socialUser->expiresIn,
'approved_scopes' => $socialUser->approvedScopes ?? [],
'person' => [
'id' => $socialUser->getId(),
'name' => $socialUser->getName(),
'avatar' => $socialUser->getAvatar(),
'vanity_name' => $this->personEnabled() ? $this->fetchVanityName($socialUser->token) : null,
],
'organizations' => $this->organizationEnabled() ? $this->fetchOrganizations($socialUser->token) : [],
],
]);
return redirect()->route('app.social.linkedin.select-identity');
} catch (\Exception $e) {
Log::error('LinkedIn OAuth Error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
public function selectIdentity(Request $request): InertiaResponse|View
{
$pending = session('linkedin_pending');
if (! $pending) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($pending['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
return Inertia::render('accounts/LinkedInSelect', [
'person' => $this->personEnabled() ? $pending['person'] : null,
'organizations' => $pending['organizations'],
]);
}
public function select(Request $request): View
{
$validator = Validator::make($request->all(), [
'type' => ['required', new Enum(LinkedInIdentityType::class)],
'organization_id' => 'required_if:type,organization',
]);
if ($validator->fails()) {
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
$validated = $validator->validated();
$pending = session('linkedin_pending');
if (! $pending) {
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
}
$workspace = Workspace::find($pending['workspace_id']);
if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) {
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
}
$type = LinkedInIdentityType::from(data_get($validated, 'type'));
if (($type === LinkedInIdentityType::Organization && ! $this->organizationEnabled())
|| ($type === LinkedInIdentityType::Person && ! $this->personEnabled())) {
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
try {
if ($type === LinkedInIdentityType::Organization) {
$organization = $this->resolveAdministeredOrganization($pending, data_get($validated, 'organization_id'));
if (! $organization) {
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
$this->connectOrganization($workspace, $pending, $organization);
} else {
$this->connectPerson($workspace, $pending);
}
session()->forget('linkedin_pending');
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (NetworkAlreadyConnectedException) {
return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value);
} catch (\Exception $e) {
Log::error('LinkedIn selection error', [
'error' => $e->getMessage(),
]);
return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value);
}
}
/**
* The user's personal LinkedIn profile becomes a `linkedin` account.
*/
private function connectPerson(Workspace $workspace, array $pending): void
{
$person = $pending['person'];
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => SocialPlatform::LinkedIn->value,
'platform_user_id' => data_get($person, 'id'),
],
[
'username' => data_get($person, 'vanity_name'),
'display_name' => data_get($person, 'name'),
'avatar_url' => uploadFromUrl(data_get($person, 'avatar')),
'access_token' => $pending['token'],
'refresh_token' => $pending['refresh_token'],
'token_expires_at' => $pending['expires_in'] ? now()->addSeconds($pending['expires_in']) : null,
'scopes' => $this->normalizeScopes($pending['approved_scopes'] ?? []),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
],
);
}
/**
* Match the chosen organization id against the admin-verified list captured at
* callback, so a tampered POST cannot connect a company the member does not
* administer.
*
* @param array<string, mixed> $pending
* @return array<string, mixed>|null
*/
private function resolveAdministeredOrganization(array $pending, mixed $organizationId): ?array
{
return collect(data_get($pending, 'organizations', []))
->first(fn ($organization) => (string) data_get($organization, 'id') === (string) $organizationId);
}
/**
* A company the user administers becomes a `linkedin-page` account, with the
* acting member recorded in meta so the page publisher can post on its behalf.
* The organization data comes from the admin-verified session list, never the
* request body.
*
* @param array<string, mixed> $pending
* @param array<string, mixed> $organization
*/
private function connectOrganization(Workspace $workspace, array $pending, array $organization): void
{
$organizationId = data_get($organization, 'id');
$workspace->socialAccounts()->updateOrCreate(
[
'platform' => SocialPlatform::LinkedInPage->value,
'platform_user_id' => $organizationId,
],
[
'username' => data_get($organization, 'vanity_name'),
'display_name' => data_get($organization, 'name'),
'avatar_url' => uploadFromUrl(data_get($organization, 'logo')),
'access_token' => $pending['token'],
'refresh_token' => $pending['refresh_token'],
'token_expires_at' => $pending['expires_in'] ? now()->addSeconds($pending['expires_in']) : null,
'scopes' => $this->normalizeScopes($pending['approved_scopes'] ?? []),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
'meta' => [
'organization_id' => $organizationId,
'admin_user_id' => data_get($pending, 'person.id'),
'admin_name' => data_get($pending, 'person.name'),
],
],
);
}
/**
* Union of the scopes for the enabled capabilities, so one consent screen
* grants exactly what the workspace can use — member posting, company-page
* administration, or both — and the user picks the identity afterwards.
*
* @return array<int, string>
*/
private function connectScopes(): array
{
$scopes = [];
if ($this->personEnabled()) {
$scopes = array_merge($scopes, config('trypost.platforms.linkedin.scopes'));
}
if ($this->organizationEnabled()) {
$scopes = array_merge($scopes, config('trypost.platforms.linkedin-page.scopes'));
}
return array_values(array_unique($scopes));
}
private function personEnabled(): bool
{
return SocialPlatform::LinkedIn->isEnabled();
}
private function organizationEnabled(): bool
{
return SocialPlatform::LinkedInPage->isEnabled();
}
/**
* LinkedIn returns approved scopes comma-joined, but Socialite splits OAuth
* scopes on space — so the whole CSV lands as a single array element. Re-split
* on commas to store individual scope tokens.
*
* @param array<int, string> $approvedScopes
* @return array<int, string>
*/
private function normalizeScopes(array $approvedScopes): array
{
return array_values(array_filter(explode(',', implode(',', $approvedScopes))));
}
private function fetchVanityName(string $accessToken): ?string
{
try {
$response = Http::withToken($accessToken)
->withHeaders(['X-RestLi-Protocol-Version' => '2.0.0'])
->get(config('trypost.platforms.linkedin.api').'/v2/me', [
'projection' => '(id,vanityName,localizedFirstName,localizedLastName)',
]);
if ($response->successful()) {
return $response->json('vanityName');
}
} catch (\Exception $e) {
Log::warning('Failed to fetch LinkedIn vanityName', [
'error' => $e->getMessage(),
]);
}
return null;
}
/**
* Organizations the authenticated member administers, used to offer company
* pages as a posting identity alongside their personal profile.
*
* @return array<int, array{id: mixed, name: string, vanity_name: ?string, logo: ?string}>
*/
private function fetchOrganizations(string $accessToken): array
{
$response = Http::withToken($accessToken)
->get(config('trypost.platforms.linkedin.api').'/v2/organizationAcls', [
'q' => 'roleAssignee',
'role' => 'ADMINISTRATOR',
'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(original~:playableStreams))))',
]);
if ($response->failed()) {
Log::error('LinkedIn Organizations fetch error', [
'error' => $response->body(),
]);
return [];
}
$organizations = [];
foreach (data_get($response->json(), 'elements', []) as $element) {
$org = data_get($element, 'organization~');
if ($org) {
$organizations[] = [
'id' => data_get($org, 'id'),
'name' => data_get($org, 'localizedName', 'Unknown'),
'vanity_name' => data_get($org, 'vanityName'),
'logo' => data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier'),
];
}
}
return $organizations;
}
}