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.
145 lines
4.6 KiB
PHP
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);
|
|
}
|
|
}
|