trypost/app/Services/Social/MastodonPublisher.php
Paulo Castellano 1660084227 refactor(social): use config for OAuth hosts everywhere
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.

Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
2026-05-19 08:34:35 -03:00

139 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\MastodonPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MastodonPublisher
{
use HasSocialHttpClient;
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
$medias = $postPlatform->post->mediaItems;
$mediaIds = [];
// Upload media first (max 4)
foreach ($medias->take(4) as $media) {
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename);
if ($mediaId) {
$mediaIds[] = $mediaId;
}
}
// Create status
$payload = [
'status' => $content ?? '',
'visibility' => 'public',
];
if (! empty($mediaIds)) {
$payload['media_ids'] = $mediaIds;
}
$response = $this->socialHttp()->withToken($account->access_token)
->post("{$instance}/api/v1/statuses", $payload);
if ($response->failed()) {
Log::error('Mastodon post failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$data = $response->json();
return [
'id' => data_get($data, 'id'),
'url' => data_get($data, 'url'),
];
}
private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string
{
$tempFile = tempnam(sys_get_temp_dir(), 'masto_media_');
try {
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
}
if (filesize($tempFile) === 0) {
Log::error('Mastodon failed to download media', ['url' => $url]);
return null;
}
// Optimize images (skip GIFs)
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon);
@unlink($tempFile);
$tempFile = $optimizedPath;
}
$name = $filename ?? basename(parse_url($url, PHP_URL_PATH));
if (empty($name)) {
$name = 'media';
}
$stream = fopen($tempFile, 'r');
$response = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name)
->post("{$instance}/api/v1/media");
if (is_resource($stream)) {
fclose($stream);
}
if ($response->failed()) {
Log::error('Mastodon media upload failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
return null;
}
$data = $response->json();
return data_get($data, 'id');
} catch (\Exception $e) {
Log::error('Mastodon media upload error', [
'error' => $e->getMessage(),
'url' => $url,
]);
return null;
} finally {
@unlink($tempFile);
}
}
private function handleApiError(Response $response): never
{
throw MastodonPublishException::fromApiResponse($response);
}
}