trypost/app/Services/Social/MastodonPublisher.php
Paulo Castellano cf045f2cdb Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 13:55:33 -03:00

145 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
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, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
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 $altText): ?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 (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) {
$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');
$request = $this->socialHttp()->withToken($account->access_token)
->attach('file', $stream, $name);
if ($altText !== null) {
$request = $request->attach('description', $altText);
}
$response = $request->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);
}
}