diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 541ded1a..a51e4895 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -44,6 +44,9 @@ enum ContentType: string // Bluesky case BlueskyPost = 'bluesky_post'; + // Mastodon + case MastodonPost = 'mastodon_post'; + public function label(): string { return match ($this) { @@ -63,6 +66,7 @@ public function label(): string self::PinterestVideoPin => 'Video Pin', self::PinterestCarousel => 'Carousel', self::BlueskyPost => 'Post', + self::MastodonPost => 'Post', }; } @@ -85,6 +89,7 @@ public function description(): string self::PinterestVideoPin => 'Video pin (4s - 15min)', self::PinterestCarousel => 'Multi-image carousel (2-5 images)', self::BlueskyPost => 'Text post with optional images', + self::MastodonPost => 'Text post with optional media', }; } @@ -101,6 +106,7 @@ public function platform(): SocialPlatform self::ThreadsPost => SocialPlatform::Threads, self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest, self::BlueskyPost => SocialPlatform::Bluesky, + self::MastodonPost => SocialPlatform::Mastodon, }; } @@ -133,6 +139,7 @@ public function maxMediaCount(): int self::PinterestPin, self::PinterestVideoPin => 1, self::PinterestCarousel => 5, self::BlueskyPost => 4, + self::MastodonPost => 4, }; } @@ -150,6 +157,7 @@ public function supportsVideo(): bool self::PinterestVideoPin => true, self::PinterestPin, self::PinterestCarousel => false, self::BlueskyPost => true, + self::MastodonPost => true, }; } @@ -171,6 +179,7 @@ public function requiresMedia(): bool self::XPost => false, self::ThreadsPost => false, self::BlueskyPost => false, + self::MastodonPost => false, default => true, }; } @@ -204,6 +213,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Threads => self::ThreadsPost, SocialPlatform::Pinterest => self::PinterestPin, SocialPlatform::Bluesky => self::BlueskyPost, + SocialPlatform::Mastodon => self::MastodonPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index a09da676..03724671 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -16,6 +16,7 @@ enum Platform: string case Threads = 'threads'; case Pinterest = 'pinterest'; case Bluesky = 'bluesky'; + case Mastodon = 'mastodon'; public function label(): string { @@ -30,6 +31,7 @@ public function label(): string self::Threads => 'Threads', self::Pinterest => 'Pinterest', self::Bluesky => 'Bluesky', + self::Mastodon => 'Mastodon', }; } @@ -45,6 +47,7 @@ public function color(): string self::Threads => '#000000', self::Pinterest => '#E60023', self::Bluesky => '#0085FF', + self::Mastodon => '#6364FF', }; } @@ -60,6 +63,7 @@ public function allowedMediaTypes(): array self::Threads => [MediaType::Image, MediaType::Video], self::Pinterest => [MediaType::Image, MediaType::Video], self::Bluesky => [MediaType::Image, MediaType::Video], + self::Mastodon => [MediaType::Image, MediaType::Video], }; } @@ -75,6 +79,7 @@ public function maxImages(): int self::Threads => 10, self::Pinterest => 5, self::Bluesky => 4, + self::Mastodon => 4, }; } @@ -90,6 +95,7 @@ public function maxContentLength(): int self::Threads => 500, self::Pinterest => 800, self::Bluesky => 300, + self::Mastodon => 500, }; } @@ -105,6 +111,7 @@ public function supportsTextOnly(): bool self::Threads => true, self::Pinterest => false, self::Bluesky => true, + self::Mastodon => true, }; } diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php new file mode 100644 index 00000000..6613b2c8 --- /dev/null +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -0,0 +1,245 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + return Inertia::render('accounts/MastodonConnect', [ + 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], + ]); + } + + /** + * Register app on instance and redirect to OAuth + */ + public function authorizeInstance(Request $request): SymfonyResponse|RedirectResponse + { + $this->ensurePlatformEnabled(); + + $request->validate([ + 'instance' => 'required|url', + ]); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + $instance = rtrim($request->instance, '/'); + + try { + // Register app on the instance + $appResponse = Http::post("{$instance}/api/v1/apps", [ + 'client_name' => config('app.name'), + 'redirect_uris' => route('social.mastodon.callback'), + 'scopes' => self::SCOPES, + 'website' => config('app.url'), + ]); + + if ($appResponse->failed()) { + Log::error('Mastodon app registration failed', [ + 'instance' => $instance, + 'status' => $appResponse->status(), + 'body' => $appResponse->body(), + ]); + + return back()->withErrors(['instance' => 'Could not connect to this Mastodon instance.']); + } + + $app = $appResponse->json(); + + // Store in session for callback + $state = bin2hex(random_bytes(16)); + session([ + 'mastodon_instance' => $instance, + 'mastodon_client_id' => $app['client_id'], + 'mastodon_client_secret' => $app['client_secret'], + 'mastodon_oauth_state' => $state, + 'social_connect_workspace' => $workspace->id, + ]); + + // Redirect to OAuth + $params = http_build_query([ + 'client_id' => $app['client_id'], + 'response_type' => 'code', + 'redirect_uri' => route('social.mastodon.callback'), + 'scope' => self::SCOPES, + 'state' => $state, + ]); + + return Inertia::location("{$instance}/oauth/authorize?{$params}"); + } catch (\Exception $e) { + Log::error('Mastodon connection error', [ + 'instance' => $instance, + 'error' => $e->getMessage(), + ]); + + return back()->withErrors(['instance' => 'Error connecting to Mastodon instance.']); + } + } + + /** + * Handle OAuth callback + */ + public function callback(Request $request): View + { + $workspaceId = session('social_connect_workspace'); + $savedState = session('mastodon_oauth_state'); + $instance = session('mastodon_instance'); + $clientId = session('mastodon_client_id'); + $clientSecret = session('mastodon_client_secret'); + + if (! $workspaceId || ! $instance) { + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Session expired. Please try again.', $this->platform->value); + } + + if ($request->state !== $savedState) { + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Invalid state. Please try again.', $this->platform->value); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Workspace not found.', $this->platform->value); + } + + try { + // Exchange code for token + $tokenResponse = Http::asForm()->post("{$instance}/oauth/token", [ + 'grant_type' => 'authorization_code', + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'redirect_uri' => route('social.mastodon.callback'), + 'code' => $request->code, + ]); + + if ($tokenResponse->failed()) { + Log::error('Mastodon token exchange failed', [ + 'status' => $tokenResponse->status(), + 'body' => $tokenResponse->body(), + ]); + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Failed to authenticate.', $this->platform->value); + } + + $tokenData = $tokenResponse->json(); + $accessToken = $tokenData['access_token']; + + // Get user profile + $profileResponse = Http::withToken($accessToken) + ->get("{$instance}/api/v1/accounts/verify_credentials"); + + if ($profileResponse->failed()) { + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Failed to get profile.', $this->platform->value); + } + + $profile = $profileResponse->json(); + + // Check existing + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Mastodon is already connected.', $this->platform->value); + } + + $avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null; + + $accountData = [ + 'platform' => $this->platform->value, + 'platform_user_id' => $profile['id'], + 'username' => $profile['acct'], + 'display_name' => $profile['display_name'] ?: $profile['username'], + 'avatar_url' => $avatarPath, + 'access_token' => $accessToken, + 'refresh_token' => null, // Mastodon tokens don't expire + 'token_expires_at' => null, + 'meta' => [ + 'instance' => $instance, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ], + ]; + + if ($existingAccount) { + $existingAccount->update($accountData); + $existingAccount->markAsConnected(); + $this->clearMastodonSession(); + + return $this->popupCallback(true, 'Mastodon account reconnected!', $this->platform->value); + } + + $accountData['status'] = Status::Connected; + $workspace->socialAccounts()->create($accountData); + + $this->clearMastodonSession(); + + return $this->popupCallback(true, 'Mastodon account connected!', $this->platform->value); + } catch (\Exception $e) { + Log::error('Mastodon callback error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $this->clearMastodonSession(); + + return $this->popupCallback(false, 'Error connecting account.', $this->platform->value); + } + } + + private function clearMastodonSession(): void + { + session()->forget([ + 'mastodon_instance', + 'mastodon_client_id', + 'mastodon_client_secret', + 'mastodon_oauth_state', + 'social_connect_workspace', + ]); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index c8027a7b..319b1eff 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -11,6 +11,7 @@ use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; +use App\Services\Social\MastodonPublisher; use App\Services\Social\PinterestPublisher; use App\Services\Social\ThreadsPublisher; use App\Services\Social\TikTokPublisher; @@ -72,7 +73,7 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -85,6 +86,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Threads => app(ThreadsPublisher::class), SocialPlatform::Pinterest => app(PinterestPublisher::class), SocialPlatform::Bluesky => app(BlueskyPublisher::class), + SocialPlatform::Mastodon => app(MastodonPublisher::class), }; } diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php new file mode 100644 index 00000000..20383cc8 --- /dev/null +++ b/app/Services/Social/MastodonPublisher.php @@ -0,0 +1,125 @@ +socialAccount; + $instance = $account->meta['instance'] ?? 'https://mastodon.social'; + + $medias = $postPlatform->media; + $mediaIds = []; + + // Upload media first (max 4) + foreach ($medias->take(4) as $media) { + $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->filename); + if ($mediaId) { + $mediaIds[] = $mediaId; + } + } + + // Create status + $payload = [ + 'status' => $postPlatform->content ?? '', + 'visibility' => 'public', + ]; + + if (! empty($mediaIds)) { + $payload['media_ids'] = $mediaIds; + } + + Log::info('Mastodon publishing status', [ + 'instance' => $instance, + 'user_id' => $account->platform_user_id, + 'has_media' => count($mediaIds) > 0, + ]); + + $response = Http::withToken($account->access_token) + ->post("{$instance}/api/v1/statuses", $payload); + + if ($response->failed()) { + Log::error('Mastodon post failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + $this->handleApiError($response); + } + + $data = $response->json(); + + Log::info('Mastodon post created', [ + 'id' => $data['id'], + 'url' => $data['url'], + ]); + + return [ + 'id' => $data['id'], + 'url' => $data['url'], + ]; + } + + private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string + { + try { + $fileContent = file_get_contents($url); + if ($fileContent === false) { + Log::error('Mastodon failed to read media', ['url' => $url]); + + return null; + } + + // Determine filename from URL if not provided + $name = $filename ?? basename(parse_url($url, PHP_URL_PATH)); + if (empty($name)) { + $name = 'media'; + } + + $response = Http::withToken($account->access_token) + ->attach('file', $fileContent, $name) + ->post("{$instance}/api/v1/media"); + + if ($response->failed()) { + Log::error('Mastodon media upload failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + $data = $response->json(); + + Log::info('Mastodon media uploaded', ['id' => $data['id']]); + + return $data['id']; + } catch (\Exception $e) { + Log::error('Mastodon media upload error', [ + 'error' => $e->getMessage(), + 'url' => $url, + ]); + + return null; + } + } + + private function handleApiError(Response $response): void + { + $body = $response->json() ?? []; + $error = $body['error'] ?? $response->body(); + + if ($response->status() === 401 || $response->status() === 403) { + throw new TokenExpiredException("Mastodon: {$error}"); + } + + throw new \Exception("Mastodon API error: {$error}"); + } +} diff --git a/config/trypost.php b/config/trypost.php index 75af179a..e5eef339 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -44,6 +44,9 @@ 'bluesky' => [ 'enabled' => env('TRYPOST_BLUESKY_ENABLED', true), ], + 'mastodon' => [ + 'enabled' => env('TRYPOST_MASTODON_ENABLED', true), + ], ], ]; diff --git a/resources/js/components/posts/previews/MastodonPreview.vue b/resources/js/components/posts/previews/MastodonPreview.vue new file mode 100644 index 00000000..fe19113a --- /dev/null +++ b/resources/js/components/posts/previews/MastodonPreview.vue @@ -0,0 +1,197 @@ + + +