feat: removed some non used files

This commit is contained in:
Paulo Castellano 2026-01-18 18:58:29 -03:00
parent d080134c65
commit ccfd39b75a
2 changed files with 0 additions and 1180 deletions

View file

@ -1,566 +0,0 @@
# Bluesky Integration Plan
## Overview
Bluesky uses o protocolo AT (ATProto) e não usa OAuth tradicional. A autenticação é feita com username/password que retorna JWT tokens.
## Arquitetura
### Autenticação
- **Não usa OAuth** - usa login com identifier (handle/email) + password
- Retorna `accessJwt` (curta duração) e `refreshJwt` (longa duração)
- Suporta instâncias customizadas (não só bsky.social)
### Endpoints Principais
- `com.atproto.server.createSession` - Login
- `com.atproto.server.refreshSession` - Refresh token
- `com.atproto.repo.createRecord` - Criar post
- `com.atproto.repo.uploadBlob` - Upload de mídia
### Limitações
- **Texto**: 300 caracteres
- **Imagens**: Máximo 4, até 1MB cada
- **Vídeo**: Máximo 1 (não pode misturar com múltiplas imagens)
---
## Implementação
### 1. Dependências
```bash
composer require socialiteproviders/bluesky
# OU usar HTTP client direto já que não é OAuth
```
Alternativa: Usar HTTP client direto (recomendado, como postiz faz).
### 2. Arquivos a Criar/Modificar
#### Novos Arquivos
- `app/Http/Controllers/Auth/BlueskyController.php` - Controller de conexão
- `app/Services/Social/BlueskyPublisher.php` - Serviço de publicação
- `resources/js/components/posts/previews/BlueskyPreview.vue` - Preview component
- `resources/js/pages/accounts/BlueskyConnect.vue` - Tela de conexão (custom, não OAuth popup)
#### Arquivos a Modificar
- `app/Enums/SocialAccount/Platform.php` - Adicionar Bluesky
- `app/Enums/PostPlatform/ContentType.php` - Adicionar BlueskyPost
- `app/Jobs/PublishToSocialPlatform.php` - Registrar BlueskyPublisher
- `config/trypost.php` - Adicionar toggle de plataforma
- `routes/web.php` - Adicionar rotas
- `resources/js/components/posts/previews/PlatformPreview.vue` - Importar BlueskyPreview
- `resources/js/components/posts/previews/index.ts` - Exportar BlueskyPreview
- `resources/js/pages/posts/Edit.vue` - Adicionar logo/label
---
### 3. Platform Enum
```php
case Bluesky = 'bluesky';
public function label(): string
{
return match ($this) {
// ...
self::Bluesky => 'Bluesky',
};
}
public function color(): string
{
return match ($this) {
// ...
self::Bluesky => '#0085FF',
};
}
public function maxContentLength(): int
{
return match ($this) {
// ...
self::Bluesky => 300,
};
}
public function maxImages(): int
{
return match ($this) {
// ...
self::Bluesky => 4,
};
}
public function supportsTextOnly(): bool
{
return match ($this) {
// ...
self::Bluesky => true,
};
}
```
---
### 4. ContentType Enum
```php
case BlueskyPost = 'bluesky_post';
public static function defaultFor(Platform $platform): ?self
{
return match ($platform) {
// ...
Platform::Bluesky => self::BlueskyPost,
};
}
```
---
### 5. BlueskyController (Conexão Custom)
Como Bluesky não usa OAuth, precisamos de uma tela custom para inserir credenciais.
```php
<?php
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Inertia\Inertia;
class BlueskyController extends Controller
{
protected SocialPlatform $platform = SocialPlatform::Bluesky;
private const API_BASE = 'https://bsky.social/xrpc';
public function connect(Request $request)
{
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
return Inertia::render('accounts/BlueskyConnect', [
'workspace' => $workspace,
]);
}
public function store(Request $request)
{
$request->validate([
'service' => 'required|url',
'identifier' => 'required|string',
'password' => 'required|string|min:3',
]);
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
// Authenticate with Bluesky
$response = Http::post($request->service . '/xrpc/com.atproto.server.createSession', [
'identifier' => $request->identifier,
'password' => $request->password,
]);
if ($response->failed()) {
return back()->withErrors(['password' => 'Invalid credentials']);
}
$data = $response->json();
// Get profile
$profileResponse = Http::withToken($data['accessJwt'])
->get($request->service . '/xrpc/app.bsky.actor.getProfile', [
'actor' => $data['did'],
]);
$profile = $profileResponse->json();
// Check existing
$existingAccount = $workspace->socialAccounts()
->where('platform', $this->platform->value)
->first();
if ($existingAccount && ! $existingAccount->isDisconnected()) {
return back()->withErrors(['identifier' => 'Bluesky is already connected.']);
}
$avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null;
$accountData = [
'platform' => $this->platform->value,
'platform_user_id' => $data['did'],
'username' => $data['handle'],
'display_name' => $profile['displayName'] ?? $data['handle'],
'avatar_url' => $avatarPath,
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'token_expires_at' => now()->addHours(2), // JWT expires quickly
'meta' => [
'service' => $request->service,
'identifier' => $request->identifier,
'password' => encrypt($request->password), // Store encrypted for re-auth
],
];
if ($existingAccount) {
$existingAccount->update($accountData);
$existingAccount->markAsConnected();
} else {
$accountData['status'] = Status::Connected;
$workspace->socialAccounts()->create($accountData);
}
session()->flash('flash.banner', 'Bluesky connected successfully!');
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('accounts');
}
}
```
---
### 6. BlueskyPublisher Service
```php
<?php
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BlueskyPublisher
{
public function publish(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? 'https://bsky.social';
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshToken($account);
$account->refresh();
}
$medias = $postPlatform->media;
$embed = null;
// Upload images if present
if ($medias->count() > 0) {
$images = [];
foreach ($medias->take(4) as $media) {
if ($media->type === 'image') {
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
$images[] = [
'alt' => '',
'image' => $blob,
];
}
}
if (count($images) > 0) {
$embed = [
'$type' => 'app.bsky.embed.images',
'images' => $images,
];
}
}
// Create post record
$record = [
'text' => $postPlatform->content ?? '',
'createdAt' => now()->toIso8601String(),
];
if ($embed) {
$record['embed'] = $embed;
}
$response = Http::withToken($account->access_token)
->post("{$service}/xrpc/com.atproto.repo.createRecord", [
'repo' => $account->platform_user_id, // DID
'collection' => 'app.bsky.feed.post',
'record' => $record,
]);
if ($response->failed()) {
Log::error('Bluesky post failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
$this->handleApiError($response);
}
$data = $response->json();
// Extract post ID from URI (at://did/app.bsky.feed.post/xxx)
$uri = $data['uri'];
$postId = basename($uri);
return [
'id' => $postId,
'url' => $this->buildPostUrl($account->username, $postId),
];
}
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): array
{
$imageContent = file_get_contents($url);
// Bluesky has 1MB limit
if (strlen($imageContent) > 1000000) {
// TODO: Resize image
}
$response = Http::withToken($account->access_token)
->withHeaders(['Content-Type' => $mimeType])
->withBody($imageContent, $mimeType)
->post("{$service}/xrpc/com.atproto.repo.uploadBlob");
if ($response->failed()) {
throw new \Exception('Failed to upload blob: ' . $response->body());
}
return $response->json()['blob'];
}
private function buildPostUrl(string $handle, string $postId): string
{
return "https://bsky.app/profile/{$handle}/post/{$postId}";
}
public function refreshToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
// Try refresh first
$response = Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'token_expires_at' => now()->addHours(2),
]);
return;
}
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => $data['accessJwt'],
'refresh_token' => $data['refreshJwt'],
'token_expires_at' => now()->addHours(2),
]);
return;
}
}
throw new TokenExpiredException('Bluesky session expired');
}
private function handleApiError($response): void
{
$body = $response->json() ?? [];
$error = $body['error'] ?? 'Unknown error';
$message = $body['message'] ?? $response->body();
if ($error === 'ExpiredToken' || $error === 'InvalidToken') {
throw new TokenExpiredException("Bluesky: {$message}");
}
throw new \Exception("Bluesky API error: {$message}");
}
}
```
---
### 7. Routes
```php
// Bluesky (custom auth, not OAuth)
Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('social.bluesky.connect');
Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('social.bluesky.store');
```
---
### 8. BlueskyConnect.vue (Frontend)
Tela customizada para inserir credenciais do Bluesky.
```vue
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
const form = useForm({
service: 'https://bsky.social',
identifier: '',
password: '',
});
const submit = () => {
form.post(route('social.bluesky.store'));
};
</script>
<template>
<Head title="Connect Bluesky" />
<AppLayout>
<div class="max-w-md mx-auto py-8 px-4">
<h1 class="text-2xl font-bold mb-6">Connect Bluesky</h1>
<form @submit.prevent="submit" class="space-y-4">
<div>
<Label for="service">Service URL</Label>
<Input
id="service"
v-model="form.service"
type="url"
placeholder="https://bsky.social"
/>
</div>
<div>
<Label for="identifier">Handle or Email</Label>
<Input
id="identifier"
v-model="form.identifier"
type="text"
placeholder="yourhandle.bsky.social"
/>
<p v-if="form.errors.identifier" class="text-sm text-red-500 mt-1">
{{ form.errors.identifier }}
</p>
</div>
<div>
<Label for="password">App Password</Label>
<Input
id="password"
v-model="form.password"
type="password"
/>
<p class="text-xs text-muted-foreground mt-1">
Use an App Password from Settings → App Passwords
</p>
<p v-if="form.errors.password" class="text-sm text-red-500 mt-1">
{{ form.errors.password }}
</p>
</div>
<Alert>
<AlertDescription>
We recommend using an App Password instead of your main password.
Create one at bsky.app → Settings → App Passwords.
</AlertDescription>
</Alert>
<Button type="submit" :disabled="form.processing" class="w-full">
{{ form.processing ? 'Connecting...' : 'Connect Bluesky' }}
</Button>
</form>
</div>
</AppLayout>
</template>
```
---
### 9. BlueskyPreview.vue
Similar ao ThreadsPreview, com layout simples de texto e imagens.
---
### 10. Assets
- Adicionar `public/images/accounts/bluesky.png` (logo do Bluesky)
---
## Ordem de Implementação
1. [ ] Adicionar Bluesky ao Platform enum
2. [ ] Adicionar BlueskyPost ao ContentType enum
3. [ ] Criar BlueskyController
4. [ ] Adicionar rotas
5. [ ] Criar BlueskyConnect.vue (frontend)
6. [ ] Criar BlueskyPublisher service
7. [ ] Registrar no PublishToSocialPlatform job
8. [ ] Criar BlueskyPreview.vue
9. [ ] Atualizar PlatformPreview.vue
10. [ ] Atualizar Edit.vue (logo, label)
11. [ ] Adicionar config em trypost.php
12. [ ] Adicionar logo
13. [ ] Testar conexão
14. [ ] Testar publicação
---
## Considerações de Segurança
- **App Password**: Recomendado usar App Password ao invés da senha principal
- **Armazenamento**: Password é encriptado no campo `meta`
- **Token Refresh**: JWT tokens expiram rápido, refresh automático implementado
---
## Diferenças do OAuth
| Aspecto | OAuth (Pinterest, etc) | Bluesky |
|---------|------------------------|---------|
| Fluxo | Popup redirect | Formulário inline |
| Tokens | Via OAuth provider | Via API direta |
| Callback | URL de callback | Não necessário |
| Refresh | Refresh token | Re-autenticação ou refresh |
---
## Sources
- [Bluesky API Get Started](https://docs.bsky.app/docs/get-started)
- [Bluesky Posts Guide](https://docs.bsky.app/docs/advanced-guides/posts)
- [Upload Blob API](https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob)
- [Postiz Bluesky Implementation](~/Code/postiz-app)

View file

@ -1,614 +0,0 @@
# Mastodon Integration Plan
## Overview
Mastodon é uma rede social federada que usa OAuth 2.0 para autenticação. Diferente de outras plataformas, cada usuário pode estar em uma instância (servidor) diferente, o que requer suporte a múltiplas instâncias.
## Arquitetura
### Autenticação
- **Usa OAuth 2.0** - fluxo padrão de autorização
- **Suporte a múltiplas instâncias** - não apenas mastodon.social
- **App registration dinâmico** - para instâncias customizadas
- **Scopes necessários**: `read:accounts`, `write:statuses`, `write:media`
### Endpoints Principais
- `POST /api/v1/apps` - Registrar aplicação (para instâncias customizadas)
- `GET /oauth/authorize` - Autorização do usuário
- `POST /oauth/token` - Obter access token
- `GET /api/v1/accounts/verify_credentials` - Dados do usuário
- `POST /api/v1/statuses` - Criar post (toot)
- `POST /api/v1/media` - Upload de mídia
### Limitações
- **Texto**: 500 caracteres (padrão, pode variar por instância)
- **Imagens**: Máximo 4 por post
- **Vídeo**: Suportado
- **Visibilidade**: public, unlisted, private, direct
---
## Implementação
### 1. Arquivos a Criar/Modificar
#### Novos Arquivos
- `app/Http/Controllers/Auth/MastodonController.php` - Controller de conexão
- `app/Services/Social/MastodonPublisher.php` - Serviço de publicação
- `resources/js/components/posts/previews/MastodonPreview.vue` - Preview component
- `resources/js/pages/accounts/MastodonConnect.vue` - Tela para inserir instância
#### Arquivos a Modificar
- `app/Enums/SocialAccount/Platform.php` - Adicionar Mastodon
- `app/Enums/PostPlatform/ContentType.php` - Adicionar MastodonPost
- `app/Jobs/PublishToSocialPlatform.php` - Registrar MastodonPublisher
- `config/services.php` - Adicionar config Mastodon (para mastodon.social padrão)
- `config/trypost.php` - Adicionar toggle de plataforma
- `routes/web.php` - Adicionar rotas
- `resources/js/components/posts/previews/PlatformPreview.vue` - Importar MastodonPreview
- `resources/js/components/posts/previews/index.ts` - Exportar MastodonPreview
- `resources/js/pages/posts/Edit.vue` - Adicionar logo/label
---
### 2. Platform Enum
```php
case Mastodon = 'mastodon';
public function label(): string
{
return match ($this) {
// ...
self::Mastodon => 'Mastodon',
};
}
public function color(): string
{
return match ($this) {
// ...
self::Mastodon => '#6364FF',
};
}
public function maxContentLength(): int
{
return match ($this) {
// ...
self::Mastodon => 500,
};
}
public function maxImages(): int
{
return match ($this) {
// ...
self::Mastodon => 4,
};
}
public function supportsTextOnly(): bool
{
return match ($this) {
// ...
self::Mastodon => true,
};
}
```
---
### 3. ContentType Enum
```php
case MastodonPost = 'mastodon_post';
public static function defaultFor(Platform $platform): ?self
{
return match ($platform) {
// ...
Platform::Mastodon => self::MastodonPost,
};
}
```
---
### 4. MastodonController
Como Mastodon requer que o usuário informe sua instância antes do OAuth:
```php
<?php
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 Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class MastodonController extends SocialController
{
protected SocialPlatform $platform = SocialPlatform::Mastodon;
private const SCOPES = 'read:accounts write:statuses write:media';
/**
* Show form to enter Mastodon instance URL
*/
public function connect(Request $request): Response|RedirectResponse
{
$this->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 authorize(Request $request): SymfonyResponse|RedirectResponse
{
$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,
'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', ['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', ['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 = 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()]);
$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',
]);
}
}
```
---
### 5. MastodonPublisher Service
```php
<?php
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MastodonPublisher
{
public function publish(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$medias = $postPlatform->media;
$mediaIds = [];
// Upload media first
foreach ($medias->take(4) as $media) {
$mediaId = $this->uploadMedia($account, $instance, $media->url);
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']]);
return [
'id' => $data['id'],
'url' => $data['url'],
];
}
private function uploadMedia(SocialAccount $account, string $instance, string $url): ?string
{
try {
$fileContent = file_get_contents($url);
if ($fileContent === false) {
return null;
}
$response = Http::withToken($account->access_token)
->attach('file', $fileContent, basename($url))
->post("{$instance}/api/v1/media");
if ($response->failed()) {
Log::error('Mastodon media upload failed', ['body' => $response->body()]);
return null;
}
return $response->json()['id'];
} catch (\Exception $e) {
Log::error('Mastodon media upload error', ['error' => $e->getMessage()]);
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}");
}
}
```
---
### 6. Routes
```php
// Mastodon (custom instance + OAuth)
Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('social.mastodon.connect');
Route::post('connect/mastodon', [MastodonController::class, 'authorize'])->name('social.mastodon.authorize');
Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('social.mastodon.callback');
```
---
### 7. MastodonConnect.vue
Tela para o usuário inserir a URL da instância Mastodon.
```vue
<script setup lang="ts">
import { ref } from 'vue';
import { IconInfoCircle } from '@tabler/icons-vue';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { authorize as authorizeMastodon } from '@/routes/social/mastodon';
interface Props {
errors?: Record<string, string>;
}
const props = defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const instance = ref('https://mastodon.social');
const isSubmitting = ref(false);
const submit = () => {
isSubmitting.value = true;
formRef.value?.submit();
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
</script>
<template>
<PopupLayout title="Connect Mastodon">
<div class="max-w-md mx-auto">
<div class="flex items-center gap-3 mb-6">
<img src="/images/accounts/mastodon.png" alt="Mastodon" class="h-12 w-12" />
<div>
<h1 class="text-xl font-bold tracking-tight">Connect Mastodon</h1>
<p class="text-sm text-muted-foreground">Enter your Mastodon instance</p>
</div>
</div>
<form
ref="formRef"
:action="authorizeMastodon.url()"
method="POST"
@submit.prevent="submit"
class="space-y-4"
>
<input type="hidden" name="_token" :value="csrfToken" />
<div class="space-y-2">
<Label for="instance">Instance URL</Label>
<Input
id="instance"
name="instance"
v-model="instance"
type="url"
placeholder="https://mastodon.social"
:class="{ 'border-destructive': errors?.instance }"
required
/>
<p v-if="errors?.instance" class="text-sm text-destructive">
{{ errors.instance }}
</p>
</div>
<Alert>
<IconInfoCircle class="h-4 w-4" />
<AlertDescription class="inline">
Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social, etc.)
</AlertDescription>
</Alert>
<Button type="submit" :disabled="isSubmitting" class="w-full">
{{ isSubmitting ? 'Connecting...' : 'Continue with Mastodon' }}
</Button>
</form>
</div>
</PopupLayout>
</template>
```
---
### 8. MastodonPreview.vue
Similar ao ThreadsPreview, estilo de timeline com texto e mídia.
---
## Ordem de Implementação
1. [ ] Adicionar Mastodon ao Platform enum
2. [ ] Adicionar MastodonPost ao ContentType enum
3. [ ] Criar MastodonController
4. [ ] Adicionar rotas
5. [ ] Criar MastodonConnect.vue (frontend)
6. [ ] Criar MastodonPublisher service
7. [ ] Registrar no PublishToSocialPlatform job
8. [ ] Criar MastodonPreview.vue
9. [ ] Atualizar PlatformPreview.vue
10. [ ] Atualizar Edit.vue (logo, label)
11. [ ] Adicionar config em trypost.php
12. [ ] Testar conexão com mastodon.social
13. [ ] Testar conexão com instância customizada
14. [ ] Testar publicação
---
## Diferenças do Bluesky
| Aspecto | Bluesky | Mastodon |
|---------|---------|----------|
| Auth | Credentials (JWT) | OAuth 2.0 |
| Instâncias | bsky.social (único) | Múltiplas (federado) |
| App Registration | Não necessário | Dinâmico por instância |
| Token Expiry | 2 horas (refresh) | Não expira |
| Char Limit | 300 | 500 |
| Callback | Não | Sim (OAuth) |
---
## Considerações
### Multi-Instance
- O usuário precisa informar sua instância antes de conectar
- App é registrado dinamicamente em cada instância
- client_id e client_secret são salvos no campo `meta` da conta
### Tokens
- Tokens Mastodon não expiram normalmente
- Não precisa de refresh token logic
- Se token inválido, usuário precisa reconectar
---
## Sources
- [Mastodon OAuth Docs](https://docs.joinmastodon.org/spec/oauth/)
- [Mastodon API - Statuses](https://docs.joinmastodon.org/methods/statuses/)
- [Mastodon API - Media](https://docs.joinmastodon.org/methods/media/)
- [Postiz App Implementation](~/Code/postiz-app)