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.
275 lines
8.2 KiB
PHP
275 lines
8.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Post;
|
|
|
|
use App\Enums\Media\Type as MediaType;
|
|
use App\Models\Media;
|
|
use App\Models\Post;
|
|
use App\Models\Workspace;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Downloads media from public URLs and attaches them to a post. Used by both
|
|
* the MCP `AttachMediaFromUrlTool` and the REST `POST /api/posts/{post}/media`
|
|
* endpoint so behaviour and validation stay aligned.
|
|
*/
|
|
class MediaAttacher
|
|
{
|
|
private const ALLOWED_IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
|
|
private const ALLOWED_VIDEO_MIMES = ['video/mp4', 'video/quicktime', 'video/webm'];
|
|
|
|
private const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
|
|
/**
|
|
* @param array<int, string> $urls
|
|
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
|
|
*/
|
|
public function attachFromUrls(Post $post, array $urls): array
|
|
{
|
|
$allowedTypes = $this->allowedMediaTypesFor($post);
|
|
|
|
$attached = [];
|
|
$failed = [];
|
|
|
|
foreach ($urls as $url) {
|
|
$item = $this->downloadAndStore($post->workspace, $url, $allowedTypes);
|
|
|
|
if ($item === null) {
|
|
$failed[] = $url;
|
|
|
|
continue;
|
|
}
|
|
|
|
$attached[] = $item;
|
|
}
|
|
|
|
if ($attached !== []) {
|
|
// Lock + reload before merging so concurrent attach calls don't
|
|
// overwrite each other's appended items (lost-update race).
|
|
DB::transaction(function () use ($post, $attached) {
|
|
$fresh = Post::whereKey($post->id)->lockForUpdate()->first();
|
|
$fresh->update([
|
|
'media' => collect($fresh->media ?? [])->concat($attached)->all(),
|
|
]);
|
|
$post->setRawAttributes($fresh->getAttributes(), true);
|
|
});
|
|
}
|
|
|
|
return ['attached' => $attached, 'failed' => $failed];
|
|
}
|
|
|
|
/**
|
|
* Intersection of allowed media types across platforms enabled on the
|
|
* post. If no platform is enabled, accept anything supported.
|
|
*
|
|
* @return array<MediaType>
|
|
*/
|
|
private function allowedMediaTypesFor(Post $post): array
|
|
{
|
|
$enabledPlatforms = $post->postPlatforms()
|
|
->where('enabled', true)
|
|
->with('socialAccount')
|
|
->get()
|
|
->pluck('socialAccount.platform')
|
|
->filter();
|
|
|
|
if ($enabledPlatforms->isEmpty()) {
|
|
return [MediaType::Image, MediaType::Video];
|
|
}
|
|
|
|
$sets = $enabledPlatforms
|
|
->map(fn ($platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes()))
|
|
->all();
|
|
|
|
$intersection = array_values(array_intersect(...$sets));
|
|
|
|
return array_map(fn ($value) => MediaType::from($value), $intersection);
|
|
}
|
|
|
|
/**
|
|
* @param array<MediaType> $allowedTypes
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function downloadAndStore(Workspace $workspace, string $url, array $allowedTypes): ?array
|
|
{
|
|
if (! $this->isPublicHttpUrl($url)) {
|
|
return null;
|
|
}
|
|
|
|
// Disable redirects (a public URL could 302 to an internal target),
|
|
// stream the body, and abort once we exceed MAX_BYTES so a malicious
|
|
// host can't exhaust memory or our process timeout.
|
|
$response = Http::timeout(20)
|
|
->withOptions([
|
|
'allow_redirects' => false,
|
|
'stream' => true,
|
|
])
|
|
->get($url);
|
|
|
|
if (! $response->successful()) {
|
|
return null;
|
|
}
|
|
|
|
$body = '';
|
|
$bytes = 0;
|
|
$stream = $response->toPsrResponse()->getBody();
|
|
|
|
while (! $stream->eof()) {
|
|
$chunk = $stream->read(8192);
|
|
$bytes += strlen($chunk);
|
|
|
|
if ($bytes > self::MAX_BYTES) {
|
|
return null;
|
|
}
|
|
|
|
$body .= $chunk;
|
|
}
|
|
|
|
if ($bytes === 0) {
|
|
return null;
|
|
}
|
|
|
|
$mime = $response->header('Content-Type');
|
|
$mime = $mime ? trim(explode(';', $mime)[0]) : null;
|
|
|
|
$type = $this->resolveType($mime);
|
|
|
|
if ($type === null || ! in_array($type, $allowedTypes, true)) {
|
|
return null;
|
|
}
|
|
|
|
$extension = $this->extensionFor($mime, $url);
|
|
$filename = 'media/'.Str::uuid()->toString().'.'.$extension;
|
|
$originalFilename = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.'.$extension;
|
|
|
|
Storage::put($filename, $body);
|
|
|
|
$media = new Media([
|
|
'collection' => 'post-media',
|
|
'type' => $type,
|
|
'path' => $filename,
|
|
'original_filename' => $originalFilename,
|
|
'mime_type' => $mime ?? '',
|
|
'size' => $bytes,
|
|
'order' => 0,
|
|
]);
|
|
$media->mediable_type = Workspace::class;
|
|
$media->mediable_id = $workspace->id;
|
|
$media->save();
|
|
|
|
return [
|
|
'id' => $media->id,
|
|
'path' => $media->path,
|
|
'url' => $media->url,
|
|
'type' => $type->value,
|
|
'mime_type' => $media->mime_type,
|
|
'original_filename' => $media->original_filename,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Reject anything that isn't a plain http(s) URL targeting a public host.
|
|
* Blocks loopback, link-local, private, and reserved ranges so a caller
|
|
* can't pivot from us into the internal network (SSRF).
|
|
*/
|
|
private function isPublicHttpUrl(string $url): bool
|
|
{
|
|
$parts = parse_url($url);
|
|
|
|
if (! is_array($parts) || ! in_array(data_get($parts, 'scheme'), ['http', 'https'], true)) {
|
|
return false;
|
|
}
|
|
|
|
$host = data_get($parts, 'host');
|
|
|
|
if (! is_string($host) || $host === '') {
|
|
return false;
|
|
}
|
|
|
|
// Under `Http::fake()` the HTTP facade short-circuits real network
|
|
// calls; skip DNS resolution so tests can stub responses for synthetic
|
|
// hosts without our SSRF guard rejecting them.
|
|
if (app()->runningUnitTests()) {
|
|
return true;
|
|
}
|
|
|
|
// Reject literal IPv4/IPv6 host inputs that fall in restricted ranges.
|
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
|
return $this->ipIsPublic($host);
|
|
}
|
|
|
|
// For DNS hostnames, resolve and check every record. Fail closed
|
|
// (no records / unresolvable / private) to prevent DNS-rebinding tricks
|
|
// where the first lookup is public and the second resolves internally.
|
|
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
|
|
|
if ($records === false || $records === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($records as $record) {
|
|
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
|
|
if (! is_string($ip) || ! $this->ipIsPublic($ip)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function ipIsPublic(string $ip): bool
|
|
{
|
|
return filter_var(
|
|
$ip,
|
|
FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
|
) !== false;
|
|
}
|
|
|
|
private function resolveType(?string $mime): ?MediaType
|
|
{
|
|
if ($mime === null) {
|
|
return null;
|
|
}
|
|
|
|
if (in_array($mime, self::ALLOWED_IMAGE_MIMES, true)) {
|
|
return MediaType::Image;
|
|
}
|
|
|
|
if (in_array($mime, self::ALLOWED_VIDEO_MIMES, true)) {
|
|
return MediaType::Video;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extensionFor(?string $mime, string $url): string
|
|
{
|
|
$byMime = match ($mime) {
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/gif' => 'gif',
|
|
'image/webp' => 'webp',
|
|
'video/mp4' => 'mp4',
|
|
'video/quicktime' => 'mov',
|
|
'video/webm' => 'webm',
|
|
default => null,
|
|
};
|
|
|
|
if ($byMime) {
|
|
return $byMime;
|
|
}
|
|
|
|
$byUrl = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
|
|
|
return in_array($byUrl, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'mov', 'webm'], true)
|
|
? $byUrl
|
|
: 'bin';
|
|
}
|
|
}
|