diff --git a/BLUESKY_IMPLEMENTATION_PLAN.md b/BLUESKY_IMPLEMENTATION_PLAN.md deleted file mode 100644 index d4680100..00000000 --- a/BLUESKY_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -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 -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 -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 - - - - - - - Connect Bluesky - - - - Service URL - - - - - Handle or Email - - - {{ form.errors.identifier }} - - - - - App Password - - - Use an App Password from Settings → App Passwords - - - {{ form.errors.password }} - - - - - - We recommend using an App Password instead of your main password. - Create one at bsky.app → Settings → App Passwords. - - - - - {{ form.processing ? 'Connecting...' : 'Connect Bluesky' }} - - - - - -``` - ---- - -### 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) diff --git a/MASTODON_IMPLEMENTATION_PLAN.md b/MASTODON_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 9d4177f4..00000000 --- a/MASTODON_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -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 -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 -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 - - - - - - - - - Connect Mastodon - Enter your Mastodon instance - - - - - - - - Instance URL - - - {{ errors.instance }} - - - - - - - Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social, etc.) - - - - - {{ isSubmitting ? 'Connecting...' : 'Continue with Mastodon' }} - - - - - -``` - ---- - -### 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)
- {{ form.errors.identifier }} -
- Use an App Password from Settings → App Passwords -
- {{ form.errors.password }} -
Enter your Mastodon instance
- {{ errors.instance }} -