Merge pull request #122 from trypostit/fix/api-media-download-host

fix: make external-URL media safe end-to-end (API hosting, X MIME, error sanitization)
This commit is contained in:
Paulo Castellano 2026-06-29 13:48:07 -03:00 committed by GitHub
commit bfa018a4cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 711 additions and 66 deletions

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Actions\Post;
use App\Enums\Media\Type as MediaType;
use App\Models\Workspace;
use App\Services\Post\MediaAttacher;
use Illuminate\Validation\ValidationException;
class HostInlineMedia
{
/**
* Resolve a post's inline media into hosted media external URLs are
* downloaded and stored; items already on our disk pass through. Rejects
* (422) when any URL can't be fetched, so a post is never persisted with
* broken media.
*
* @param array<MediaType> $allowedTypes
* @param array<int, array<string, mixed>> $media
* @return array<int, array<string, mixed>>
*
* @throws ValidationException
*/
public static function execute(Workspace $workspace, array $allowedTypes, array $media): array
{
if ($media === []) {
return $media;
}
$result = app(MediaAttacher::class)->resolveInlineMedia($workspace, $allowedTypes, $media);
if ($result['failed'] !== []) {
throw ValidationException::withMessages([
'media' => ['Could not fetch media from URL: '.implode(', ', $result['failed'])],
]);
}
return $result['media'];
}
}

View file

@ -6,6 +6,7 @@
use App\Actions\Post\CreatePost;
use App\Actions\Post\DeletePost;
use App\Actions\Post\HostInlineMedia;
use App\Actions\Post\UpdatePost;
use App\Enums\Media\Type as MediaType;
use App\Enums\Post\Action as PostAction;
@ -49,11 +50,18 @@ public function show(Request $request, Post $post): PostResource
public function store(StorePostRequest $request): JsonResponse
{
$post = CreatePost::execute(
$request->user()->currentWorkspace,
$request->user()->currentWorkspace->owner,
$request->validated()
);
$workspace = $request->user()->currentWorkspace;
$data = $request->validated();
if (array_key_exists('media', $data)) {
$data['media'] = HostInlineMedia::execute(
$workspace,
Post::allowedMediaTypesFor($request->selectedPlatforms()),
$data['media'],
);
}
$post = CreatePost::execute($workspace, $workspace->owner, $data);
$post->load(['postPlatforms.socialAccount']);
@ -66,7 +74,17 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
{
$this->authorize('update', $post);
$result = UpdatePost::execute($request->user()->currentWorkspace, $post, $request->validated());
$data = $request->validated();
if (array_key_exists('media', $data)) {
$data['media'] = HostInlineMedia::execute(
$request->user()->currentWorkspace,
$post->allowedMediaTypes(),
$data['media'],
);
}
$result = UpdatePost::execute($request->user()->currentWorkspace, $post, $data);
if (data_get($result, 'action') === PostAction::Finalized) {
return response()->json(

View file

@ -9,6 +9,7 @@
use App\Models\SocialAccount;
use App\Rules\ContentFitsPlatformLimits;
use App\Rules\ContentTypeMatchesPlatform;
use App\Support\PostMediaRules;
use App\Support\PostPlatformMetaRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Collection;
@ -35,7 +36,7 @@ public function rules(): array
[new ContentFitsPlatformLimits($this->resolveSelectedPlatforms($workspaceId))]
),
],
'media' => ['sometimes', 'array'],
...PostMediaRules::rules(hosted: false),
'platforms' => ['required', 'array', 'min:1'],
'platforms.*.social_account_id' => [
'required',
@ -60,6 +61,14 @@ public function rules(): array
];
}
/**
* @return Collection<int, Platform>
*/
public function selectedPlatforms(): Collection
{
return $this->resolveSelectedPlatforms($this->user()->currentWorkspace->id)->values();
}
/**
* @return Collection<int|string, Platform>
*/

View file

@ -12,6 +12,7 @@
use App\Rules\ContentFitsPlatformLimits;
use App\Rules\ContentTypeCompatibleWithMedia;
use App\Rules\ContentTypeMatchesPostPlatform;
use App\Support\PostMediaRules;
use App\Support\PostPlatformMetaRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Collection;
@ -44,7 +45,7 @@ public function rules(): array
[new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())]
),
],
'media' => ['sometimes', 'array'],
...PostMediaRules::rules(hosted: false),
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))],
'platforms.*.content_type' => [

View file

@ -4,12 +4,12 @@
namespace App\Http\Requests\App\Post;
use App\Enums\Media\Source;
use App\Enums\Post\Status;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Rules\ContentFitsPlatformLimits;
use App\Rules\ContentTypeCompatibleWithMedia;
use App\Support\PostMediaRules;
use App\Support\PostPlatformMetaRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Collection;
@ -42,17 +42,7 @@ public function rules(): array
[new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())]
),
],
'media' => ['sometimes', 'array'],
'media.*.id' => ['required', 'string'],
'media.*.path' => ['required', 'string', 'max:500'],
'media.*.url' => ['required', 'string', 'max:2048'],
'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'],
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.size' => ['sometimes', 'nullable', 'integer'],
'media.*.meta' => ['sometimes', 'nullable', 'array'],
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
...PostMediaRules::rules(hosted: true),
'scheduled_at' => [
'sometimes',
'nullable',

View file

@ -165,7 +165,7 @@ public function handle(): void
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
]);
$this->postPlatform->markAsFailed($e->getMessage(), [
$this->postPlatform->markAsFailed($this->safeFailureMessage($e), [
'category' => 'unknown',
'failed_at' => now()->toIso8601String(),
'content_length' => mb_strlen($this->postPlatform->post->content ?? ''),
@ -223,6 +223,17 @@ private function broadcastStatus(): void
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
}
/**
* A user-safe failure message: only our own publish exceptions are shown
* verbatim; anything else is genericized so internals never reach the email.
*/
private function safeFailureMessage(\Throwable $e): string
{
return $e instanceof SocialPublishException
? $e->userMessage
: 'An unexpected error occurred while publishing. Please try again.';
}
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher
{
return match ($this->postPlatform->platform) {
@ -308,10 +319,13 @@ public function failed(?\Throwable $exception): void
$this->postPlatform->refresh();
if ($this->postPlatform->status !== PostPlatformStatus::Published) {
$this->postPlatform->markAsFailed($exception?->getMessage() ?? 'Unknown error', [
'category' => 'job_failed',
'failed_at' => now()->toIso8601String(),
]);
$this->postPlatform->markAsFailed(
$exception ? $this->safeFailureMessage($exception) : 'Unknown error',
[
'category' => 'job_failed',
'failed_at' => now()->toIso8601String(),
]
);
$this->updatePostStatus();
$this->broadcastStatus();
}

View file

@ -7,6 +7,7 @@
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Observers\PostObserver;
use Database\Factories\PostFactory;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
@ -150,12 +151,23 @@ public function allowedMediaTypes(): array
->pluck('socialAccount.platform')
->filter();
return self::allowedMediaTypesFor($platforms);
}
/**
* Media types acceptable across a set of platforms (intersection; empty = all).
*
* @param Collection<int, Platform> $platforms
* @return array<Type>
*/
public static function allowedMediaTypesFor(Collection $platforms): array
{
if ($platforms->isEmpty()) {
return Type::cases();
}
$sets = $platforms
->map(fn ($platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes()))
->map(fn (Platform $platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes()))
->all();
return array_map(

View file

@ -5,9 +5,12 @@
namespace App\Services\Post;
use App\Enums\Media\Type as MediaType;
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
/**
* Downloads public URLs and attaches them as media to a post used by
@ -31,7 +34,7 @@ public function attachFromUrls(Post $post, array $urls): array
$failed = [];
foreach ($urls as $url) {
($item = $this->attachOne($post, $url)) === null
($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null
? $failed[] = $url
: $attached[] = $item;
}
@ -44,9 +47,54 @@ public function attachFromUrls(Post $post, array $urls): array
}
/**
* Resolve an inline media array into hosted items: items with a `path` pass
* through, external URLs are downloaded and hosted. Atomic when any item
* fails, media hosted in this call is rolled back so nothing is orphaned.
*
* @param array<MediaType> $allowedTypes
* @param array<int, array<string, mixed>> $items
* @return array{media: array<int, array<string, mixed>>, failed: array<int, string>}
*/
public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, array $items): array
{
$media = [];
$failed = [];
$hostedIds = [];
foreach ($items as $item) {
if (filled(data_get($item, 'path'))) {
$media[] = $item;
continue;
}
$url = (string) data_get($item, 'url', '');
$hosted = $this->fetchToWorkspace($workspace, $allowedTypes, $url);
if ($hosted === null) {
$failed[] = $url;
continue;
}
$media[] = $hosted;
$hostedIds[] = data_get($hosted, 'id');
}
if ($failed !== [] && $hostedIds !== []) {
Media::query()->whereKey($hostedIds)->get()->each->delete();
}
return ['media' => $media, 'failed' => $failed];
}
/**
* Download a URL, validate its type, and store it on the workspace.
*
* @param array<MediaType> $allowedTypes
* @return array<string, mixed>|null
*/
private function attachOne(Post $post, string $url): ?array
private function fetchToWorkspace(Workspace $workspace, array $allowedTypes, string $url): ?array
{
$download = $this->download($url);
@ -57,7 +105,7 @@ private function attachOne(Post $post, string $url): ?array
try {
$type = MediaType::fromMime($download['mime'] ?? '');
if ($type === null || ! in_array($type, $post->allowedMediaTypes(), true)) {
if ($type === null || ! in_array($type, $allowedTypes, true)) {
return null;
}
@ -66,7 +114,7 @@ private function attachOne(Post $post, string $url): ?array
}
$name = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin';
$media = $post->workspace->addMediaFromPath($download['path'], $name, 'assets');
$media = $workspace->addMediaFromPath($download['path'], $name, 'assets');
return [
'id' => $media->id,
@ -106,7 +154,8 @@ private function download(string $url): ?array
},
])
->get($url);
} catch (RuntimeException) {
} catch (Throwable) {
// Any fetch failure (timeout, DNS, refused, oversize) is a failed download.
@unlink($temp);
return null;

View file

@ -6,6 +6,7 @@
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
@ -160,13 +161,19 @@ private function publishDocument(?string $content, $media, string $title): array
$document = $media->first(fn ($item) => $item->isDocument());
if (! $document) {
throw new \Exception("No PDF document for {$this->label()} document post");
throw new LinkedInPublishException(
userMessage: "No PDF document was found for this {$this->label()} document post.",
category: ErrorCategory::MediaFormat,
);
}
$documentUrn = $this->uploadDocument($document);
if (! $documentUrn) {
throw new \Exception("{$this->label()} document upload failed");
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the document. Please try again.",
category: ErrorCategory::ServerError,
);
}
$payload = $this->basePayload($content);
@ -262,7 +269,10 @@ private function uploadImage($mediaItem): ?string
$imageUrn = data_get($initData, 'value.image');
if (! $uploadUrl || ! $imageUrn) {
throw new \Exception("{$this->label()} image upload init missing uploadUrl or image URN");
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the image upload. Please try again.",
category: ErrorCategory::ServerError,
);
}
$tempFile = tempnam(sys_get_temp_dir(), 'li_image_');
@ -337,7 +347,10 @@ private function doUploadVideo(string $tempFile, $mediaItem): ?string
$uploadToken = data_get($initData, 'value.uploadToken', '');
if (! $videoUrn || empty($uploadInstructions)) {
throw new \Exception("{$this->label()} video upload init missing video URN or upload instructions");
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the video upload. Please try again.",
category: ErrorCategory::ServerError,
);
}
$uploadedPartIds = [];
@ -414,7 +427,10 @@ private function uploadDocument($mediaItem): ?string
$documentUrn = data_get($initData, 'value.document');
if (! $uploadUrl || ! $documentUrn) {
throw new \Exception("{$this->label()} document upload init missing uploadUrl or document URN");
throw new LinkedInPublishException(
userMessage: "{$this->label()} did not accept the document upload. Please try again.",
category: ErrorCategory::ServerError,
);
}
// The Documents API is not chunked — upload the PDF in a single request.
@ -475,13 +491,19 @@ private function waitForProcessing(string $resource, string $assetUrn, string $l
}
if ($status === 'PROCESSING_FAILED') {
throw new \Exception("{$this->label()} {$label} processing failed");
throw new LinkedInPublishException(
userMessage: "{$this->label()} {$label} processing failed.",
category: ErrorCategory::ServerError,
);
}
sleep($this->processingPollSeconds());
}
throw new \Exception("{$this->label()} {$label} processing did not complete in time");
throw new LinkedInPublishException(
userMessage: "{$this->label()} {$label} processing did not complete in time.",
category: ErrorCategory::ServerError,
);
}
/**
@ -505,7 +527,10 @@ private function downloadToTempFile(string $url, string $tempFile): void
$response = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
if ($response->failed()) {
throw new \Exception('Failed to download media: HTTP '.$response->status());
throw new LinkedInPublishException(
userMessage: "Could not fetch the media to upload to {$this->label()}. Please try again.",
category: ErrorCategory::ServerError,
);
}
}

View file

@ -55,7 +55,10 @@ public function publish(PostPlatform $postPlatform): array
ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()),
ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()),
ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $aspectRatio),
default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"),
default => throw new FacebookPublishException(
userMessage: "Unsupported Facebook content type: {$contentType?->value}",
category: ErrorCategory::MediaFormat,
),
};
}
@ -64,7 +67,10 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
// Text only post
if ($media->isEmpty()) {
if ($content === null || $content === '') {
throw new \Exception('Facebook text posts require content. Please add text to your post.');
throw new FacebookPublishException(
userMessage: 'Facebook text posts require content. Please add text to your post.',
category: ErrorCategory::MediaFormat,
);
}
return $this->publishTextPost($pageId, $accessToken, $content);
@ -87,7 +93,10 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
return $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio);
}
throw new \Exception('Unsupported media type for Facebook');
throw new FacebookPublishException(
userMessage: 'Unsupported media type for Facebook',
category: ErrorCategory::MediaFormat,
);
}
private function publishTextPost(string $pageId, string $accessToken, string $content): array
@ -173,7 +182,10 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
}
if (empty($attachedMedia)) {
throw new \Exception('Failed to upload any images to Facebook');
throw new FacebookPublishException(
userMessage: 'Failed to upload any images to Facebook',
category: ErrorCategory::ServerError,
);
}
// Create the post with attached media
@ -360,7 +372,10 @@ private function publishStory(string $pageId, string $accessToken, $media): arra
$videoId = $response->json()['video_id'] ?? null;
if (! $videoId) {
throw new \Exception('Facebook story upload failed: no video ID returned');
throw new FacebookPublishException(
userMessage: 'Facebook did not accept the story video. Please try again.',
category: ErrorCategory::ServerError,
);
}
$transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [

View file

@ -5,6 +5,8 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\LinkedInPublishException;
/**
* Publishes posts to a LinkedIn company page on behalf of an administering member.
@ -21,7 +23,10 @@ protected function authorUrn(): string
$organizationId = $this->account->meta['organization_id'] ?? null;
if (! $organizationId) {
throw new \Exception('LinkedIn Page organization ID not configured');
throw new LinkedInPublishException(
userMessage: 'LinkedIn Page organization ID not configured',
category: ErrorCategory::Permission,
);
}
return "urn:li:organization:{$organizationId}";

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\ThreadsPublishException;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -41,7 +42,10 @@ public function publish(PostPlatform $postPlatform): array
// Text only post
if ($media->isEmpty()) {
if (empty($content)) {
throw new \Exception('Threads text posts require content. Please add text to your post.');
throw new ThreadsPublishException(
userMessage: 'Threads text posts require content. Please add text to your post.',
category: ErrorCategory::MediaFormat,
);
}
return $this->publishTextPost($userId, $accessToken, $content);
@ -83,7 +87,10 @@ private function publishTextPost(string $userId, string $accessToken, string $co
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Threads text container creation failed: no container ID returned');
throw new ThreadsPublishException(
userMessage: 'Threads did not accept the post. Please try again.',
category: ErrorCategory::ServerError,
);
}
// Step 2: Publish
@ -111,7 +118,10 @@ private function publishImagePost(string $userId, string $accessToken, ?string $
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Threads image container creation failed: no container ID returned');
throw new ThreadsPublishException(
userMessage: 'Threads did not accept the image. Please try again.',
category: ErrorCategory::ServerError,
);
}
// Step 2: Wait for image processing
@ -142,7 +152,10 @@ private function publishVideoPost(string $userId, string $accessToken, ?string $
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new \Exception('Threads video container creation failed: no container ID returned');
throw new ThreadsPublishException(
userMessage: 'Threads did not accept the video. Please try again.',
category: ErrorCategory::ServerError,
);
}
// Wait for video processing
@ -198,7 +211,10 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
}
if (empty($childContainers)) {
throw new \Exception('Failed to create any carousel items');
throw new ThreadsPublishException(
userMessage: 'Failed to create any carousel items',
category: ErrorCategory::ServerError,
);
}
// Step 2: Create carousel container
@ -219,7 +235,10 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
$carouselId = $carouselResponse->json()['id'] ?? null;
if (! $carouselId) {
throw new \Exception('Threads carousel container creation failed: no container ID returned');
throw new ThreadsPublishException(
userMessage: 'Threads did not accept the carousel. Please try again.',
category: ErrorCategory::ServerError,
);
}
// Step 3: Publish carousel
@ -244,7 +263,10 @@ private function publishContainer(string $userId, string $accessToken, string $c
$mediaId = $publishResponse->json()['id'] ?? null;
if (! $mediaId) {
throw new \Exception('Threads publish failed: no media ID returned');
throw new ThreadsPublishException(
userMessage: 'Threads did not accept the post. Please publish again.',
category: ErrorCategory::ServerError,
);
}
// Get permalink
@ -289,14 +311,20 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
if ($status === 'ERROR') {
$errorMessage = data_get($data, 'error_message', 'Unknown error');
throw new \Exception('Threads media processing failed: '.$errorMessage);
throw new ThreadsPublishException(
userMessage: 'Threads media processing failed. Please try a different file.',
category: ErrorCategory::ServerError,
);
}
sleep(3);
}
Log::warning('Threads media processing timeout', ['container_id' => $containerId]);
throw new \Exception('Threads media processing timeout after '.$maxAttempts.' attempts');
throw new ThreadsPublishException(
userMessage: 'Threads took too long to process the media. Please try again.',
category: ErrorCategory::ServerError,
);
}
private function handleApiError(Response $response): never

View file

@ -6,6 +6,7 @@
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\XPublishException;
use App\Models\PostPlatform;
use App\Services\Media\MediaOptimizer;
@ -71,7 +72,10 @@ public function publish(PostPlatform $postPlatform): array
}
if (empty($content) && empty($mediaIds)) {
throw new \Exception('X posts require either text or media. Please add content to your post.');
throw new XPublishException(
userMessage: 'X posts require either text or media. Please add content to your post.',
category: ErrorCategory::MediaFormat,
);
}
$response = $this->getHttpClient()
@ -114,11 +118,25 @@ private function uploadMedia($mediaItem): ?array
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
throw new XPublishException(
userMessage: 'Could not fetch the media to upload to X. Please try again.',
category: ErrorCategory::ServerError,
);
}
if (blank($mimeType)) {
$mimeType = mime_content_type($tempFile) ?: null;
}
if (blank($mimeType)) {
throw new XPublishException(
userMessage: 'Unsupported media type for X.',
category: ErrorCategory::MediaFormat,
);
}
// Optimize images (skip GIFs — they need special handling)
if ($mediaItem->isImage() && ! MediaType::isGif($mimeType)) {
if (MediaType::classify($mimeType) === MediaType::Image && ! MediaType::isGif($mimeType)) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X);
@unlink($tempFile);
@ -177,16 +195,21 @@ private function uploadMedia($mediaItem): ?array
}
}
private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array
private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, ?string $mediaCategory): array
{
$initPayload = [
'media_type' => $mimeType,
'total_bytes' => $totalBytes,
];
if ($mediaCategory) {
$initPayload['media_category'] = $mediaCategory;
}
// INIT
$initResponse = $this->socialHttp()->withToken($this->accessToken)
->timeout(60)
->post("{$this->baseUrl}/media/upload/initialize", [
'media_type' => $mimeType,
'media_category' => $mediaCategory,
'total_bytes' => $totalBytes,
]);
->post("{$this->baseUrl}/media/upload/initialize", $initPayload);
if ($initResponse->failed()) {
Log::error('X chunked upload INIT error', [
@ -200,7 +223,10 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
$mediaId = $initData['data']['id'] ?? $initData['media_id'] ?? null;
if (! $mediaId) {
throw new \Exception('No media_id returned from INIT');
throw new XPublishException(
userMessage: 'X did not accept the media upload. Please try again.',
category: ErrorCategory::ServerError,
);
}
// APPEND - Read from temp file in 1MB chunks. Matches the

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\Media\Source;
use Illuminate\Validation\Rule;
/**
* Single source of truth for inline post `media` validation, shared by the post
* create/update flows. The web sends already-hosted media (id + path) and tracks
* its source; the public REST API may send a bare external `url` we download and
* host, so the id/path/url rules differ by contract.
*/
class PostMediaRules
{
/**
* @param bool $hosted true (web): items must already be hosted (id + path
* required); false (API): a bare external `url` is
* accepted (and downloaded).
* @return array<string, mixed>
*/
public static function rules(bool $hosted): array
{
return [
'media' => ['sometimes', 'array'],
'media.*.id' => $hosted ? ['required', 'string'] : ['sometimes', 'nullable', 'string'],
'media.*.path' => $hosted ? ['required', 'string', 'max:500'] : ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.url' => $hosted
? ['required', 'string', 'max:2048']
: ['required', 'string', 'max:2048', 'url:http,https'],
'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'],
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.size' => ['sometimes', 'nullable', 'integer'],
'media.*.meta' => ['sometimes', 'nullable', 'array'],
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
];
}
}

View file

@ -8,6 +8,7 @@
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
@ -219,3 +220,229 @@
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
});
it('downloads and hosts an external media url when creating a post', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/listing.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'External media post',
'media' => [['url' => 'https://cdn.example.com/listing.jpg']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertCreated();
$media = Post::where('content', 'External media post')->firstOrFail()->media;
expect($media)->toHaveCount(1)
->and(data_get($media, '0.path'))->not->toBeNull()
->and(data_get($media, '0.url'))->not->toContain('cdn.example.com');
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
});
it('rejects creating a post when an external media url cannot be fetched', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Broken media post',
'media' => [['url' => 'https://cdn.example.com/missing.jpg']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
expect(Post::where('content', 'Broken media post')->exists())->toBeFalse();
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
});
it('rolls back already-hosted media when another url in the batch fails', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/good.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
'cdn.example.com/missing.jpg' => Http::response(null, 404),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Partial media post',
'media' => [
['url' => 'https://cdn.example.com/good.jpg'],
['url' => 'https://cdn.example.com/missing.jpg'],
],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
expect(Post::where('content', 'Partial media post')->exists())->toBeFalse();
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
});
it('rejects and rolls back when a media url connection fails (timeout/dns)', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/good.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
'cdn.example.com/timeout.jpg' => fn () => throw new ConnectionException('Connection timed out'),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Timeout media post',
'media' => [
['url' => 'https://cdn.example.com/good.jpg'],
['url' => 'https://cdn.example.com/timeout.jpg'],
],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
expect(Post::where('content', 'Timeout media post')->exists())->toBeFalse();
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
});
it('rejects creating a post when an external media url is not a supported type', function () {
$this->socialAccount->update(['is_active' => true]);
// Downloads fine (200) but the bytes are not a supported media type.
Http::fake(['cdn.example.com/notes.txt' => Http::response('just some text', 200)]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Bad type post',
'media' => [['url' => 'https://cdn.example.com/notes.txt']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
expect(Post::where('content', 'Bad type post')->exists())->toBeFalse();
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
});
it('keeps an already-hosted item and a freshly-hosted url in order', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/external.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Mixed media post',
'media' => [
['id' => 'hosted-1', 'path' => 'assets/already.jpg', 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'],
['url' => 'https://cdn.example.com/external.jpg'],
],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertCreated();
$media = Post::where('content', 'Mixed media post')->firstOrFail()->media;
expect($media)->toHaveCount(2)
->and(data_get($media, '0.path'))->toBe('assets/already.jpg')
->and(data_get($media, '1.url'))->not->toContain('cdn.example.com')
->and(data_get($media, '1.path'))->not->toBeNull();
// Only the external URL is hosted; the passed-through item creates no new row.
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
});
it('passes already-hosted media through on create without downloading', function () {
$this->socialAccount->update(['is_active' => true]);
Http::preventStrayRequests();
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Hosted media post',
'media' => [[
'id' => 'media-1',
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
]],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertCreated();
expect(data_get(Post::where('content', 'Hosted media post')->firstOrFail()->media, '0.path'))->toBe('assets/foo.jpg');
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0);
});
it('downloads and hosts an external media url when updating a post', function () {
Http::fake([
'cdn.example.com/listing.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [['url' => 'https://cdn.example.com/listing.jpg']],
])
->assertOk();
$media = $this->post->fresh()->media;
expect($media)->toHaveCount(1)
->and(data_get($media, '0.path'))->not->toBeNull()
->and(data_get($media, '0.url'))->not->toContain('cdn.example.com');
});
it('rejects updating a post when an external media url cannot be fetched', function () {
Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]);
$original = $this->post->fresh()->media;
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [['url' => 'https://cdn.example.com/missing.jpg']],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media']);
expect($this->post->fresh()->media)->toBe($original);
});

View file

@ -181,7 +181,57 @@
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe('API Error');
expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.');
});
test('publish keeps the vetted user message from a publish exception', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new LinkedInPublishException(
userMessage: 'LinkedIn rejected this post.',
category: ErrorCategory::ContentPolicy,
));
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe('LinkedIn rejected this post.');
});
test('publish never leaks a raw internal error to the failure record (and the email)', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new TypeError(
'X::getMediaCategory(): Argument #1 ($mimeType) must be of type string, null given, called in /home/forge/app.trypost.it/releases/72198060/app/Services/Social/XPublisher.php on line 130'
));
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.')
->and($this->postPlatform->error_message)->not->toContain('/home/forge')
->and($this->postPlatform->error_message)->not->toContain('getMediaCategory');
});
test('the job-failed hook also genericizes a raw internal error', function () {
Event::fake();
(new PublishToSocialPlatform($this->postPlatform))->failed(new TypeError(
'boom in /home/forge/app.trypost.it/releases/72198060/app/Services/Social/XPublisher.php on line 130'
));
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed)
->and($this->postPlatform->error_message)->toBe('An unexpected error occurred while publishing. Please try again.')
->and($this->postPlatform->error_message)->not->toContain('/home/forge');
});
test('publish to social platform marks account as token expired on auth failure', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
@ -789,7 +790,7 @@ protected function processingPollSeconds(): int
});
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'missing uploadUrl or document URN');
->toThrow(LinkedInPublishException::class, 'did not accept the document upload');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});

View file

@ -4,12 +4,14 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\XPublisher;
use Illuminate\Support\Facades\Http;
@ -252,6 +254,46 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/gif_media_555'));
});
test('x publisher recovers a missing mime type from the downloaded bytes', function () {
$this->post->update([
'media' => [
['url' => 'https://cdn.example.com/listing'],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'x_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/media/upload')) {
return Http::response(['data' => ['id' => 'media_id_111']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '1212121212', 'text' => 'Hello from X!']], 200);
}
return Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('1212121212');
Http::assertSent(fn ($request) => str_contains($request->url(), '/media/upload'));
});
test('x publisher uploads video via chunked upload', function () {
$this->post->update([
'media' => [
@ -353,3 +395,16 @@
expect($appendCount)->toBeGreaterThan(1);
});
test('x publisher fails cleanly when media cannot be downloaded', function () {
$this->post->update([
'media' => [
['url' => 'https://cdn.example.com/listing', 'mime_type' => 'image/jpeg'],
],
]);
Http::fake(['cdn.example.com/listing' => Http::response(null, 404)]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(XPublishException::class, 'Could not fetch the media to upload to X');
});

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Support\PostMediaRules;
test('hosted media rules require id and path', function () {
$rules = PostMediaRules::rules(hosted: true);
expect($rules['media.*.id'])->toContain('required')
->and($rules['media.*.path'])->toContain('required')
->and($rules['media.*.url'])->not->toContain('url:http,https');
});
test('api media rules accept a bare external url with nullable id/path', function () {
$rules = PostMediaRules::rules(hosted: false);
expect($rules['media.*.id'])->toContain('nullable')
->and($rules['media.*.path'])->toContain('nullable')
->and($rules['media.*.url'])->toContain('url:http,https');
});
test('both variants keep the shared item keys so validated() preserves them', function () {
foreach ([true, false] as $hosted) {
expect(PostMediaRules::rules(hosted: $hosted))->toHaveKeys([
'media.*.id',
'media.*.path',
'media.*.url',
'media.*.type',
'media.*.mime_type',
'media.*.original_filename',
'media.*.size',
'media.*.meta',
]);
}
});