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.
139 lines
4.3 KiB
PHP
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);
|
|
}
|
|
}
|