Code-review surfaced two correctness bugs and a security gap that
needed to land before merging.
- UpdatePost::execute disabled every platform when called without
a `platforms` key. PublishPostTool relied on that path, so every
publish-via-MCP queued a job whose handler then found nothing
enabled to publish to. Wrap the platform toggle in
`Arr::has($data, 'platforms')` (matches the existing label_ids
guard a few lines up). Add a regression assertion to
`PostPublishToolTest::publish post immediate dispatches PublishPost
job` that the previously-enabled platform stays enabled.
- StorePostRequest declared rules for only `platforms`,
`scheduled_at`, and `status`. `validated()` then stripped
`content`, `media`, and `label_ids`, so REST `POST /api/posts`
silently created empty drafts. Added rules for content / media /
label_ids (with workspace-scoped `Rule::exists` for labels) and
dropped the unused `status` field — REST callers transition state
via `PUT /posts/{id}`. Removed the dead `platforms.*.content`
rule. Added a feature test that asserts content + media + labels
roundtrip on create, plus a regression that an `is_active=false`
social_account is rejected at validation.
- CreatePost::execute now syncs label_ids itself so REST and MCP
share the behavior. Removed the duplicate sync from CreatePostTool.
- MCP UpdatePostTool didn't scope `platforms.*.id` to the post being
updated, drifting from the REST UpdatePostRequest which adds
`Rule::exists('post_platforms','id')->where('post_id', ...)`. Now
it loads the post first (failing fast with `Post not found.` if
the workspace check rejects), then uses the same Rule::exists.
- MediaAttacher fetched any URL the caller passed, including
loopback / link-local / private targets — classic SSRF pivot.
Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP
ranges, and DNS hostnames whose A/AAAA records resolve into those
ranges (covers DNS rebinding). Bypassed under
`app()->runningUnitTests()` so `Http::fake()` keeps working.
Streaming the response body lets us abort early once we exceed
MAX_BYTES instead of buffering the full payload first; redirects
are disabled so a 200→302 trick can't bypass the host check.
- The `media[]` JSON column had a lost-update race in
`attachFromUrls`: read `$post->media`, mutate in PHP, write back.
Two concurrent calls clobbered each other. Now wrapped in a
transaction with `lockForUpdate()`.
- ESLint: `resources/js/actions/**` and `resources/js/routes/**`
are auto-generated by Wayfinder on every build. Their import
order matches PHP scan order, not alphabetical, so import/order
fought eslint-fix forever. Added them to ignores.
90 lines
2.8 KiB
PHP
90 lines
2.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Actions\Post;
|
|
|
|
use App\Enums\Post\Status as PostStatus;
|
|
use App\Models\Post;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class CreatePost
|
|
{
|
|
/**
|
|
* Create a Post with optional platform selection.
|
|
*
|
|
* `platforms[]` enables specific social accounts. Each entry takes
|
|
* `social_account_id` and an optional `content_type` (defaults to the
|
|
* platform's default). Accounts not listed remain disabled but are still
|
|
* created via SyncPostPlatforms so the user can toggle them later in the
|
|
* editor.
|
|
*
|
|
* `label_ids[]` are attached after creation so the same set of UUIDs
|
|
* works for REST, MCP, and web callers.
|
|
*
|
|
* @param array{
|
|
* content?: ?string,
|
|
* media?: array<int, mixed>,
|
|
* date?: ?string,
|
|
* scheduled_at?: ?string,
|
|
* platforms?: array<int, array{social_account_id: string, content_type?: string}>,
|
|
* label_ids?: array<int, string>
|
|
* } $data
|
|
*/
|
|
public static function execute(Workspace $workspace, User $user, array $data): Post
|
|
{
|
|
$scheduledAt = self::resolveScheduledAt($data);
|
|
|
|
return DB::transaction(function () use ($workspace, $user, $data, $scheduledAt): Post {
|
|
$post = $workspace->posts()->create([
|
|
'user_id' => $user->id,
|
|
'content' => data_get($data, 'content', ''),
|
|
'media' => data_get($data, 'media', []),
|
|
'status' => PostStatus::Draft,
|
|
'scheduled_at' => $scheduledAt,
|
|
]);
|
|
|
|
SyncPostPlatforms::execute($post);
|
|
|
|
foreach (data_get($data, 'platforms', []) as $platformData) {
|
|
$accountId = data_get($platformData, 'social_account_id');
|
|
if (! $accountId) {
|
|
continue;
|
|
}
|
|
|
|
$updates = ['enabled' => true];
|
|
|
|
if ($contentType = data_get($platformData, 'content_type')) {
|
|
$updates['content_type'] = $contentType;
|
|
}
|
|
|
|
$post->postPlatforms()
|
|
->where('social_account_id', $accountId)
|
|
->update($updates);
|
|
}
|
|
|
|
if ($labelIds = data_get($data, 'label_ids')) {
|
|
$post->labels()->sync($labelIds);
|
|
}
|
|
|
|
return $post;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
private static function resolveScheduledAt(array $data): Carbon
|
|
{
|
|
if ($scheduledAt = data_get($data, 'scheduled_at')) {
|
|
return Carbon::parse($scheduledAt)->utc();
|
|
}
|
|
|
|
$date = data_get($data, 'date') ?: Carbon::now('UTC')->format('Y-m-d');
|
|
|
|
return Carbon::parse($date, 'UTC')->setTime(9, 0)->utc();
|
|
}
|
|
}
|