feat: complete create + publish post flow via MCP and REST API#14
Merged
Conversation
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
- Add `php artisan passport:keys --force` step to tests workflow so the OAuth2 server keys exist before tests run. Without them every API test failed with `LogicException: Invalid key supplied` from `vendor/league/oauth2-server/src/CryptKey.php`. - Clear 4 pre-existing unused-variable lint errors that were blocking this PR's CI (auth/GoogleAuthButton, billing/Subscribe, posts/editor/CommentsTab, posts/editor/TikTokSettings). - `eslint --fix` also reordered imports alphabetically in 11 other files — pure cosmetic.
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.
Both ContentTypeMatchesPlatform and ContentTypeMatchesPostPlatform
were using preg_replace to swap the leaf segment of a dotted attribute
path ("platforms.0.content_type" -> "platforms.0.social_account_id").
Cleaner with Str::beforeLast: derive the parent path once, then look
up the sibling under it. Same intent, no regex, and the rule no longer
encodes its own attribute name.
The same "is this post in the user's current workspace?" check was
duplicated across every Post-related endpoint (5 in Api/PostController
via the ensurePostInCurrentWorkspace helper, 5 in App/PostController
inline). PostPolicy already had a duplicate() method following this
exact pattern — extending it with view/update/delete unifies the
tenancy guard in one place.
- Add view/update/delete to PostPolicy. Each returns
Response::denyAsNotFound() when the post belongs to a different
workspace, so we keep the existing 404 behavior (don't leak
cross-tenant existence) instead of switching to the default 403.
- Update duplicate() to also use denyAsNotFound() for the workspace
mismatch path. The createPost role check still returns bool/403.
- Replace ensurePostInCurrentWorkspace() calls in Api/PostController
with $this->authorize('view'|'update'|'delete', $post). Helper deleted.
- Replace inline workspace_id !== $workspace->id checks in
App/PostController (show/edit/update/destroy/platformMetrics) with
the same authorize calls. The PostPolicy guard now subsumes both
the workspace-tenancy check and the role-permission check that was
previously delegated through Workspace::createPost.
The previous suite asserted happy paths and a couple of basic field omissions but didn't probe the rules themselves. Adds 26 tests across 5 files: REST API (tests/Feature/Api/PostApiTest.php) — 9 new: - content_type not in the enum - content_type mismatched with the social account's platform - label_id from another workspace - platforms[].id from another post on update (cross-post leak) - content_type mismatched with the post_platform on update - status=scheduled requires future scheduled_at - status=draft works with no scheduled_at - past scheduled_at on store MCP create-post-tool (tests/Feature/Mcp/PostToolTest.php) — 5 new: - inactive social account - content_type not in the enum - content_type mismatched with the social account's platform - label_id from another workspace - already had: scheduled_at past MCP update-post-tool (tests/Feature/Mcp/PostPublishToolTest.php) — 2 new: - platforms[].id from another post (regression for the new Rule::exists scoping) - content_type mismatched with the post_platform MCP attach-media-from-url-tool (tests/Feature/Mcp/AttachMediaFromUrlToolTest.php) — 3 new: - non-http(s) scheme (ftp://...) - malformed url string - more than 10 URLs per call Custom rules unit tests — 2 new files: - ContentTypeMatchesPlatformTest covers happy path, cross-platform mismatch, the Instagram + InstagramFacebook compatibility bridge, and the no-op cases (missing account_id, unknown content_type — those are caught by Rule::in elsewhere). - ContentTypeMatchesPostPlatformTest covers the equivalent shape for the update flow that pivots through post_platform.id.
… guard The previous MediaAttacher had ~5 responsibilities crammed in one class (URL validation, HTTP fetch, streaming, MIME check, Storage write, post.media merge) plus an `if ($app->runningUnitTests()) return true` inside the SSRF guard — production code that knew about test mode. Split into three: - UrlSafetyGuard (interface) + DnsUrlSafetyGuard (default impl). The full SSRF check is now isolated and unit-testable in tests/Unit/Services/Post/DnsUrlSafetyGuardTest. Tests bind a permissive guard in tests/TestCase::setUp so feature tests using Http::fake() with synthetic hosts (cdn.example.com) still work — the runningUnitTests() check inside production code is gone. - MediaDownloader: takes a URL, returns a temp file + MIME or null. Uses Http::sink with a Guzzle progress callback that throws once maxBytes is exceeded, so we abort mid-stream without buffering the body in PHP memory (the previous chunk loop was append-to-string which defeated the whole point of streaming). - MediaAttacher: pure orchestrator. Calls MediaDownloader, validates the MIME against the post's enabled platforms, persists via Storage::putFileAs, appends the Media record under a row lock. Drops from 275 to ~190 lines with zero responsibility overlap. The IO contract (`null` on failure, `['path','mime','bytes']` on success) keeps MediaAttacher free of the temp-file lifecycle on the happy path — ownership is documented in MediaDownloader's PHPDoc and the orchestrator unlinks via try/finally in processOne(). Bind the interface in AppServiceProvider so production resolves DnsUrlSafetyGuard automatically; tests override it. 7 new unit tests cover the SSRF cases (loopback, RFC1918, link-local, zero/broadcast, IPv6 loopback/ULA/link-local, plus a public-IP positive test). The 14 existing AttachMedia feature tests stay green.
The previous split (MediaAttacher + MediaDownloader + UrlSafetyGuard interface + DnsUrlSafetyGuard impl + provider binding) was over-engineered for one production implementation. Roll it all back into a single MediaAttacher class and delegate to the existing `Workspace::addMediaFromPath` helper for storage + Media row creation (the same path the web upload flow uses). What's gone: - app/Services/Post/MediaDownloader.php - app/Services/Post/UrlSafetyGuard.php (interface) - app/Services/Post/DnsUrlSafetyGuard.php (impl) - tests/Unit/Services/Post/DnsUrlSafetyGuardTest.php - The provider bind in AppServiceProvider What's still defended: - Streaming download via Http::sink with a Guzzle progress callback that aborts mid-stream once MAX_BYTES is exceeded (no body buffered in PHP). - Redirects disabled so a 200→302 trick can't pivot to internal hosts. - IP-literal SSRF guard: rejects loopback / private / link-local / reserved ranges via FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE. DNS hostnames are accepted — finer SSRF (DNS rebinding etc.) is left to network-level egress controls. This is a deliberate simplification: the prior DNS resolution defense added a class + interface + provider binding for marginal gain when the realistic attack surface is hard-coded internal IPs. - Strict MIME allowlist (no SVG, no PDF, no application/*). - Lock-then-merge into post.media[] to avoid lost-update races. Tests bypass the SSRF check via the static `MediaAttacher::fakeUrlSafety()` called once in tests/TestCase::setUp — same idiom as Mail::fake / Bus::fake.
Both PostAiCreateController::createMediaItem and
PostTemplateController::createMediaItem were doing:
$media = new Media([...]);
$media->mediable_type = Workspace::class; // ← FQCN literal
$media->mediable_id = $workspace->id;
$media->save();
Assigning Workspace::class directly bypasses the morphMap configured in
AppServiceProvider, so rows ended up with mediable_type =
'App\\Models\\Workspace' instead of the alias 'workspace'. Other queries
that pivot through the morphMap (e.g. $workspace->media) lose those
records on hosts where the FQCN doesn't match the alias.
Switch both to the relationship form:
$media = $workspace->media()->create([...]);
Laravel fills mediable_type via the morphMap, producing 'workspace'.
This is the same pattern AssetController and the HasMedia trait already
use; these two AI controllers were the only outliers (`grep -rn
'mediable_type =' app/` confirms).
Added tests/Unit/MediaPolymorphTest as a regression — asserts the
created row's mediable_type is the alias and that the relationship
resolves back to the workspace.
The enum already has `allowedMimeTypes()` — it's the single source of truth for what each media type accepts (jpeg/png/gif/webp for image, mp4/quicktime/webm for video). MediaAttacher was duplicating the same arrays as ALLOWED_IMAGE_MIMES / ALLOWED_VIDEO_MIMES constants, which meant any change to the supported MIMEs needed two edits. Drop the constants. `resolveType()` now walks `[MediaType::Image, MediaType::Video]` and asks each type whether the MIME is on its allow-list. Document type is intentionally excluded so PDFs aren't accepted via URL fetch. Note on size: kept MediaAttacher's own MAX_BYTES = 50 MB (separate from the enum's per-type caps). URL fetches have different operational constraints than direct uploads (bandwidth, timeout, unbounded user input) — a smaller cap here is intentional. Out of scope but worth a follow-up: `StoreAssetRequest` and `storeChunked` are also drifted (web only accepts video/mp4 today, the enum + MediaAttacher accept mov/webm too). They should also read from the enum eventually.
The MediaAttacher used to roll its own SSRF guard with DNS resolution and a static fakeUrlSafety() flag for tests. Validating URLs is a request-layer concern, not a service-layer one. Laravel ships 'active_url' which does the same DNS resolvability check via dns_get_record — applying it at the FormRequest / MCP validate() level catches dead URLs upfront with a proper 422 instead of letting the download silently fail. - Replace the inline 'urls.*' => ['url:http,https'] rule with ['url:http,https', 'active_url'] in both Api/PostController::attachMedia and Mcp/Tools/Post/AttachMediaFromUrlTool. - Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety from MediaAttacher. The remaining defenses (Http::sink streaming + progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist) cover the operational concerns. - Restore tests/TestCase to the original setUp — no SSRF bypass needed anymore because active_url is satisfied by the test hosts. - Swap synthetic test hosts (cdn.example.com / evil.example.com) for example.com / example.org. Both are RFC-reserved AND have stable A records, so active_url accepts them while Http::fake() still intercepts the actual request. For SSRF defense beyond 'active_url' (which doesn't block private IPs), trypost relies on production network egress controls. Open-source self-hosters who run without a firewall accept the corresponding risk; that's a deployment concern, not a request validation concern.
Project convention is one FormRequest per endpoint
(Api/Post/StorePostRequest, UpdatePostRequest, etc.) — the inline
$request->validate() in attachMedia was the only outlier in this
controller. Extracted to Api/Post/AttachMediaRequest with the same
rules:
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
Controller signature is now AttachMediaRequest $request — Laravel
binds + validates before the action runs, same pattern as store/update.
Three things in one move:
1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
The MediaType enum now reads from there:
MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB)
MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)
Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
don't have to multiply themselves. StoreAssetRequest now uses
MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
validation moved to the new StoreChunkedAssetRequest FormRequest
(also reads from the enum). MediaAttacher uses
MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
and enforces the per-type cap after MIME resolution.
2. Drop MediaType::Document. We never accepted PDFs anywhere — the
StoreAssetRequest mimes list excluded them, the storeChunked
extension regex excluded them, MediaAttacher excluded them. The only
places that referenced Document were:
- Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
but unreachable)
- HasMedia::getMediaType fallback when MIME wasn't image/video/*
Both now cleaned up. HasMedia::getMediaType throws
InvalidArgumentException for unsupported MIMEs instead of silently
returning a fake 'document' type. Platform::LinkedIn now matches
every other social platform: [Image, Video].
3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
type loop that MediaAttacher used to roll. Returns null for
unsupported MIMEs (caller decides how to react).
Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
asserts the throw on unsupported MIMEs. The 'add media from path'
test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
(FormRequest unprocessable) for malformed Content-Range headers,
matching the new validation layer.
… upload
Two regex pieces in StoreChunkedAssetRequest, both replaced by more
fitting tools:
1. Parsing 'Content-Range' header — preg_match with capturing groups
and $matches[1..3] indexing replaced by sscanf, which is
purpose-built for parsing structured strings with a known format:
$parsed = sscanf($header, 'bytes %d-%d/%d') ?: [];
$start = $parsed[0] ?? null;
No regex, no $matches array, no manual int casts (sscanf returns ints
for %d).
2. Validating filename suffix — regex:/\.(jpe?g|png|gif|webp|mp4)$/i
replaced by Laravel's built-in 'ends_with' rule, with extensions
derived from the MediaType enum:
$allowedSuffixes = collect([Image, Video])
->flatMap(fn (MediaType $t) => $t->extensions())
->map(fn ($ext) => '.'.$ext)
->all();
For the case-insensitive part, normalize the filename to lowercase
in prepareForValidation so 'IMG_1234.JPG' matches '.jpg' naturally.
Added MediaType::extensions() — single source of truth for filename
suffixes (jpg/jpeg/png/gif/webp + mp4/mov/webm). Mirrors
allowedMimeTypes() for callers that validate by name instead of MIME.
Previous attempt dropped both video/quicktime and video/webm citing narrow platform support. Re-investigated: - Modern .mov files (iPhone recordings, screen captures) are ISO BMFF containers — the same format MP4 uses. Social platforms decode them like MP4 even when PHP's mime_content_type() reports 'video/quicktime'. Postiz quietly accepts MOV via this same trick: their file-type lib detects the magic bytes as 'video/mp4' and renames the upload before storage. - WebM is genuinely incompatible (Matroska + VP8/VP9 stack) — X / IG / TikTok / FB / Pinterest / Bluesky / Threads all reject it. So allow MOV, keep WebM out. Rejecting MOV would force every iPhone user to transcode before uploading — real UX cost for no gain since the publishers handle it.
MediaAttacher had grown into a 180-line class with four private
methods doing different things — download, validation, storage,
and post-state mutation. Two of those (lock-and-merge into the
JSON media column, intersection of allowed media types per
platform) are post concerns, not network concerns.
Move them onto the Post model:
Post::allowedMediaTypes(): array<Type>
The intersection of media types accepted by every enabled
platform. Used to be inlined as MediaAttacher::allowedMediaTypesFor.
Post::appendMedia(array $items): void
Lock-then-merge into the post's media[] JSON column so
concurrent writers don't overwrite each other. Used to be
MediaAttacher::mergeIntoPostMedia.
MediaAttacher now reads cleanly:
attachFromUrls — orchestrate (loop URLs, batch the appendMedia)
attachOne — download → validate type/size → handoff
download — Http::sink + progress abort, returns DTO
Drops to ~120 lines with no nested concerns. The streaming progress
abort uses the same throw-RuntimeException pattern, but now
contained in download() instead of leaking into the orchestrator.
…tents instead of relying on unreliable Content-Type headers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CreatePost::executepreviously validatedplatforms[]but silently ignored it. RESTPOST /api/postscallers never saw their selection persisted; now they do.ContentTypeMatchesPlatform,ContentTypeMatchesPostPlatform) reject mismatched combos like LinkedIn+x_post. Inactive social accounts are also rejected at validation time instead of failing silently downstream.PostMetricsFetcher,PostPreviewer,MediaAttacher) back both MCP tools and REST controllers — no duplicate logic.What's new
MCP tools (8)
UpdatePostTool— update content/platforms/labels/schedulePublishPostTool— trigger publish (immediate or scheduled)PreviewPostTool— per-platform sanitized previewGetPostMetricsTool— engagement metrics per platformAttachMediaFromUrlTool— fetch + store media from public URLsListContentTypesTool— discover valid content_types per platform with limitsCreatePostTool(revamp) — acceptsplatforms[],scheduled_at,label_idsListPostsTool(revamp) — filters by status, search, limitREST endpoints (4)
POST /api/posts/{post}/media— attach from URLsGET /api/posts/{post}/metrics— engagementGET /api/posts/{post}/preview— per-platform render previewGET /api/content-types— platform/content_type catalogResources (4)
PlatformContentTypesResourcePostMediaAttachResourcePostMetricsResourcePostPreviewResourceServices (3)
PostMetricsFetcher— used by web, REST, MCPPostPreviewer— used by REST + MCPMediaAttacher— used by REST + MCPValidation rules (2)
ContentTypeMatchesPlatform(forplatforms[].social_account_idflow)ContentTypeMatchesPostPlatform(forplatforms[].idflow)End-to-end flow
MCP:
REST:
Test plan
PostControllerTest(web Inertia) — 49 testsPostApiTest(REST CRUD) — 17 testsPlatformApiTest(REST content-types) — 2 testsPostMediaApiTest(REST media/preview/metrics) — 9 testsMcp/*(all tools) — 66 testsPublishToSocialPlatformTest(publish job) — 19 tests/api/*Cleanup
docs/from git tracking (already in.gitignore)TIKTOK_REVIEW_VIDEO_SCRIPT.md🤖 Generated with Claude Code