fix(media): treat any download failure (timeout/DNS/refused) as a failed fetch

MediaAttacher::download() only caught RuntimeException, but a connection-level
failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not
RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the
API hosting path that propagated as a 500 (not the promised 422), skipped the
batch rollback (orphaning an already-hosted item), and leaked the temp file.

Catch Throwable so any fetch failure returns null → the caller rejects cleanly
with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths
(a timeout there now reports a failed URL instead of 500).
This commit is contained in:
Paulo Castellano 2026-06-29 13:41:47 -03:00
parent a6bb39cc1a
commit a63e9ea778
2 changed files with 34 additions and 1 deletions

View file

@ -10,6 +10,7 @@
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
@ -153,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

@ -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;
@ -299,6 +300,36 @@
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]);