Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/Http/Requests/Api/Post/StorePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

class StorePostRequest extends FormRequest
{
Expand Down Expand Up @@ -61,6 +62,17 @@ public function rules(): array
];
}

public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
PostMediaRules::assertHostedMediaExists(
$validator,
$this->user()->currentWorkspace,
(array) $this->input('media', []),
);
});
}

/**
* @return array<string, string>
*/
Expand Down
8 changes: 8 additions & 0 deletions app/Http/Requests/Api/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ public function attributes(): array

public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
PostMediaRules::assertHostedMediaExists(
$validator,
$this->user()->currentWorkspace,
(array) $this->input('media', []),
);
});

$validator->after(function (Validator $validator): void {
if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) {
return;
Expand Down
15 changes: 14 additions & 1 deletion app/Http/Requests/App/Post/StorePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace App\Http\Requests\App\Post;

use App\Support\PostMediaRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;

class StorePostRequest extends FormRequest
{
Expand All @@ -20,7 +22,18 @@ public function rules(): array
{
return [
'date' => ['nullable', 'date_format:Y-m-d'],
'media' => ['nullable', 'array'],
...PostMediaRules::rules(hosted: true),
];
}

public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
PostMediaRules::assertHostedMediaExists(
$validator,
$this->user()->currentWorkspace,
(array) $this->input('media', []),
);
});
}
}
8 changes: 8 additions & 0 deletions app/Http/Requests/App/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ public function attributes(): array

public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
PostMediaRules::assertHostedMediaExists(
$validator,
$this->user()->currentWorkspace,
(array) $this->input('media', []),
);
});

$validator->after(function (Validator $validator): void {
if (! $this->isPublishingOrScheduling()) {
return;
Expand Down
68 changes: 68 additions & 0 deletions app/Support/PostMediaRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
namespace App\Support;

use App\Enums\Media\Source;
use App\Models\Media;
use App\Models\Workspace;
use Closure;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;

/**
* Single source of truth for inline post `media` validation, shared by the post
Expand Down Expand Up @@ -62,4 +67,67 @@ public static function rules(bool $hosted): array
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
];
}

/**
* Reject inline media items that claim to already be hosted (`id` and/or
* `path` set) but don't resolve to a real `medias` row owned by this
* workspace. `rules()` above only checks shape (id is a non-empty string).
* It never confirms the id exists, so a client could otherwise write an
* arbitrary id/path pair straight into `posts.media`, including another
* workspace's real asset (IDOR) or a path nothing backs at all. Mirrors
* the lookup `FindWorkspaceAsset` already uses for `attach-existing-asset`.
*
* A bare `url` with no `id`/`path` is left alone: that's the API-only
* "please download this external URL" case (`PostMediaRules::rules`
* with `hosted: false`), and `HostInlineMedia`/`MediaAttacher` handle it
* by fetching the URL and creating a fresh, workspace-owned `Media` row
* before anything is persisted. There's no pre-existing id to check yet.
*
* @param array<int, array<string, mixed>> $media
*/
public static function assertHostedMediaExists(Validator $validator, Workspace $workspace, array $media): void
{
foreach ($media as $index => $item) {
$id = data_get($item, 'id');
$path = data_get($item, 'path');

if (blank($id)) {
if (filled($path)) {
$validator->errors()->add(
"media.{$index}.id",
'The media id field is required when path is present.',
);
}

// Blank id, blank path: a bare external url for HostInlineMedia to fetch. Nothing to verify yet.
continue;
}

if ($validator->errors()->has("media.{$index}.id")) {
// A shape rule (e.g. "must be a string") already failed for this item.
continue;
}

// A non-UUID id can never match a real medias row (id is a UUID
// primary key), and Postgres rejects it as an invalid uuid literal
// before the query even runs, an unhandled 500 instead of a
// graceful 422. Fail the same way a real, absent id would.
if (! Str::isUuid((string) $id)) {
$validator->errors()->add("media.{$index}.id", 'Media not found.');

continue;
}

$exists = Media::query()
->where('mediable_type', Relation::getMorphAlias(Workspace::class))
->where('mediable_id', $workspace->id)
->where('collection', 'assets')
->whereKey($id)
->exists();

if (! $exists) {
$validator->errors()->add("media.{$index}.id", 'Media not found.');
}
}
}
}
7 changes: 6 additions & 1 deletion tests/Feature/Api/PostApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Jobs\PublishPost;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
Expand Down Expand Up @@ -101,10 +102,14 @@

it('creates a post with content, media, and labels', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
]);

$payload = [
'content' => 'Hello from the API',
'media' => [['id' => 'media-1', 'path' => 'media/foo.jpg', 'url' => 'https://example.com/foo.jpg', 'type' => 'image']],
'media' => [['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://example.com/'.$asset->path, 'type' => 'image']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
Expand Down
47 changes: 39 additions & 8 deletions tests/Feature/Api/PostMediaApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,12 @@
it('keeps an already-hosted item and a freshly-hosted url in order', function () {
$this->socialAccount->update(['is_active' => true]);

$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'path' => 'assets/already.jpg',
]);

Http::fake([
'93.184.216.34/external.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
Expand All @@ -502,7 +508,7 @@
->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'],
['id' => $asset->id, 'path' => $asset->path, 'url' => 'https://cdn.trypost.test/assets/already.jpg', 'type' => 'image'],
['url' => 'https://93.184.216.34/external.jpg'],
],
'platforms' => [
Expand All @@ -517,19 +523,25 @@
->and(data_get($media, '0.path'))->toBe('assets/already.jpg')
->and(data_get($media, '1.url'))->not->toContain('93.184.216.34')
->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);
// The pre-existing asset is reused (no duplicate row); only the external url is newly hosted.
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(2);
});

it('passes already-hosted media through on create without downloading', function () {
$this->socialAccount->update(['is_active' => true]);
Http::preventStrayRequests();

$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'path' => 'assets/foo.jpg',
]);

$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Hosted media post',
'media' => [[
'id' => 'media-1',
'id' => $asset->id,
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
Expand All @@ -541,7 +553,8 @@
->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);
// The pre-existing asset is reused; nothing new is downloaded or hosted.
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
});

it('downloads and hosts an external media url when updating a post', function () {
Expand Down Expand Up @@ -587,11 +600,17 @@
$this->socialAccount->update(['is_active' => true]);
Http::preventStrayRequests();

$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'path' => 'assets/foo.jpg',
]);

$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Alt text post',
'media' => [[
'id' => 'media-1',
'id' => $asset->id,
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
Expand All @@ -609,11 +628,17 @@
});

it('accepts and persists media alt text on update', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'path' => 'assets/foo.jpg',
]);

$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [[
'id' => 'media-1',
'id' => $asset->id,
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
Expand All @@ -626,11 +651,17 @@
});

it('preserves every media meta key on update, not just alt_text', function () {
$asset = Media::factory()->assets()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'path' => 'assets/foo.jpg',
]);

$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [[
'id' => 'media-1',
'id' => $asset->id,
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
Expand Down
Loading