feat: removed some non used files
This commit is contained in:
parent
d080134c65
commit
ccfd39b75a
2 changed files with 0 additions and 1180 deletions
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in a new issue