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:
commit
bfa018a4cd
19 changed files with 711 additions and 66 deletions
42
app/Actions/Post/HostInlineMedia.php
Normal file
42
app/Actions/Post/HostInlineMedia.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}", [
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
app/Support/PostMediaRules.php
Normal file
42
app/Support/PostMediaRules.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
36
tests/Unit/Support/PostMediaRulesTest.php
Normal file
36
tests/Unit/Support/PostMediaRulesTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue