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.
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.
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.
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.
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.
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.
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.
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 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.
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 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.
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.
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.
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.
- 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.
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.