Skip to content
Merged
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
29 changes: 29 additions & 0 deletions app/Models/MediaPostPlatform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\Pivot;

/**
* Pivot for `PostPlatform::media()`: which of a post's media items apply to a
* given platform. A dedicated `id` primary key (via HasUuids) instead of the
* plain composite-key pivot used elsewhere (see `post_workspace_label`) is
* deliberate: it makes an individual row directly addressable for debugging
* (single id in a log line, `firstOrFail()` by id) instead of needing both
* foreign keys every time. The `(media_id, post_platform_id)` uniqueness is
* still enforced separately by the migration's own unique index.
*
* HasUuids generates the id in the model's `creating` event, which only fires
* for a custom pivot class (`belongsToMany(...)->using(self::class)`). The
* plain `attach()` path used without `using()` bypasses model events entirely
* and would leave `id` null.
*/
class MediaPostPlatform extends Pivot
{
use HasUuids;

protected $table = 'media_post_platform';
}
40 changes: 40 additions & 0 deletions app/Models/PostPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Models;

use App\Dto\MediaItem;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
Expand All @@ -13,6 +14,8 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;

class PostPlatform extends Model
Expand Down Expand Up @@ -63,6 +66,43 @@ public function socialAccount(): BelongsTo
return $this->belongsTo(SocialAccount::class);
}

/**
* The subset of the post's media items scoped to this platform. Empty
* unless a caller has explicitly narrowed which media this platform
* publishes (per-platform media selection).
*/
public function media(): BelongsToMany
{
return $this->belongsToMany(Media::class, 'media_post_platform')
->using(MediaPostPlatform::class)
->withTimestamps();
}

/**
* The media items this platform actually publishes. An empty pivot set
* for this platform means "no per-platform selection was made", which is
* today's behaviour: fall back to every media item on the post. This is
* the one place that decides that, so every publisher and validator
* reads media through this helper instead of `$postPlatform->post->mediaItems`
* directly, or the two would silently disagree once a selection exists.
*
* @return Collection<int, MediaItem>
*/
public function scopedMediaItems(): Collection
{
$selectedIds = $this->media()->pluck('medias.id');

$allMediaItems = $this->post->mediaItems;

if ($selectedIds->isEmpty()) {
return $allMediaItems;
}

return $allMediaItems->filter(
fn (MediaItem $item) => $selectedIds->contains($item->id)
)->values();
}

/**
* Only platforms still enabled for publishing — disabled ones are
* excluded from PublishPost, so anything else that mirrors publish
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.');
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Pivot for per-platform media selection: which of a post's media items
* apply to a given platform. Additive and fully backward compatible:
* an empty pivot set for a platform means "all of the post's media",
* today's behaviour, so no special-casing is needed anywhere reading it.
*
* A dedicated `id` primary key (instead of the plain composite-key pivot
* used by `post_workspace_label`) is deliberate: it makes an individual
* row directly addressable for debugging (single id in a log line,
* `firstOrFail()` by id) rather than needing both foreign keys every
* time. Uniqueness is still enforced separately below.
*/
public function up(): void
{
Schema::create('media_post_platform', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('media_id');
$table->uuid('post_platform_id');
$table->timestamps();

$table->foreign('media_id')->references('id')->on('medias')->cascadeOnDelete();
$table->foreign('post_platform_id')->references('id')->on('post_platforms')->cascadeOnDelete();

$table->unique(['media_id', 'post_platform_id']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('media_post_platform');
}
};
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
Loading