fix(api): download and host external media URLs on post create/update

The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').

Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.

- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
  without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
  validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
This commit is contained in:
Paulo Castellano 2026-06-28 17:28:05 -03:00
parent 80dc1ab381
commit 81d43c30f4
6 changed files with 251 additions and 11 deletions

View file

@ -18,6 +18,7 @@
use App\Http\Resources\Api\PostPreviewResource;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Models\Workspace;
use App\Services\Post\MediaAttacher;
use App\Support\PostStatusRules;
use Illuminate\Http\JsonResponse;
@ -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'] = $this->hostInlineMedia(
$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'] = $this->hostInlineMedia(
$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(
@ -81,6 +99,36 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
return new PostResource($updated);
}
/**
* Download and host any external media URLs in an inline media array so the
* stored post never references a third-party URL (which can 404 or change at
* publish time). Items already on our storage pass through. Rejects the
* request 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
*/
private function hostInlineMedia(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'];
}
public function destroy(Request $request, Post $post): JsonResponse
{
$this->authorize('delete', $post);

View file

@ -36,6 +36,14 @@ public function rules(): array
),
],
'media' => ['sometimes', 'array'],
'media.*.id' => ['sometimes', 'nullable', 'string'],
'media.*.path' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.url' => ['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'],
'platforms' => ['required', 'array', 'min:1'],
'platforms.*.social_account_id' => [
'required',
@ -60,6 +68,17 @@ public function rules(): array
];
}
/**
* The distinct platforms selected in this request, used to compute the
* media types acceptable for the post being created.
*
* @return Collection<int, Platform>
*/
public function selectedPlatforms(): Collection
{
return $this->resolveSelectedPlatforms($this->user()->currentWorkspace->id)->values();
}
/**
* @return Collection<int|string, Platform>
*/

View file

@ -45,6 +45,14 @@ public function rules(): array
),
],
'media' => ['sometimes', 'array'],
'media.*.id' => ['sometimes', 'nullable', 'string'],
'media.*.path' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.url' => ['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'],
'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

@ -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,26 @@ public function allowedMediaTypes(): array
->pluck('socialAccount.platform')
->filter();
return self::allowedMediaTypesFor($platforms);
}
/**
* The media types acceptable across a set of platforms: the intersection of
* what each platform allows. With no platform, accept anything. Lets callers
* that don't have a persisted Post yet (e.g. the API create flow) compute the
* same constraint from the platforms in the request.
*
* @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

@ -6,6 +6,7 @@
use App\Enums\Media\Type as MediaType;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use RuntimeException;
@ -31,7 +32,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 +45,47 @@ public function attachFromUrls(Post $post, array $urls): array
}
/**
* Resolve an inline media array (as accepted by the public API on post
* create/update) into fully hosted media items. Items already stored on our
* disk (they carry a `path`) pass through untouched; every other item is
* treated as an external `url` to download and host, so publishing never
* depends on a third-party URL staying alive.
*
* @param array<Type> $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 = [];
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)) === null
? $failed[] = $url
: $media[] = $hosted;
}
return ['media' => $media, 'failed' => $failed];
}
/**
* Download a public URL, validate it against the accepted media types, and
* store it on the workspace. Returns the media item, or null on any failure
* (download error, disallowed type, oversized).
*
* @param array<Type> $allowedTypes
* @return array<string, mixed>|null
*/
private function attachOne(Post $post, string $url): ?array
public function fetchToWorkspace(Workspace $workspace, array $allowedTypes, string $url): ?array
{
$download = $this->download($url);
@ -57,7 +96,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 +105,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,

View file

@ -219,3 +219,114 @@
->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('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);
});