fix: harden backend media constraints and allow square YouTube Shorts - #287
fix: harden backend media constraints and allow square YouTube Shorts#287HafizMMoaz wants to merge 3 commits into
Conversation
YouTube accepts Shorts that are square or vertical up to 3 minutes, but our aspect-ratio window only allowed ~9:16 (0.5-0.6), rejecting 1:1 uploads the platform actually supports.
ContentTypeCompatibleWithMedia only checked media type compatibility (image/video/document, mixed media) even though ContentType::mediaRules() already exposes the full numeric rule set (max/min files, byte caps, max video duration, aspect ratio bounds, GIF support) that the Vue editor already enforces via useMediaRules/useMedia. Scheduling or publishing an over-limit post via API or MCP silently skipped these checks since they only existed in the frontend. Extend the rule with the same checks, applied per platform's content type against the shared post-level media set, matching frontend behavior exactly for what's already client-supplied: duration and dimensions come from media.*.meta, which is not yet server-probed (no ffprobe in this codebase), so a missing value is skipped rather than rejected, same as the frontend's `?? 0` fallback.
There was a problem hiding this comment.
Pull request overview
This PR hardens backend media validation so web/API/MCP enforce the same numeric media constraints as the editor (count limits, size caps, duration, aspect ratio, GIF support), and updates YouTube Shorts to allow square (1:1) media in addition to vertical, including user-facing copy.
Changes:
- Expanded
ContentTypeCompatibleWithMediafrom type-only checks to include file count bounds, GIF acceptance, per-type byte limits, max video duration, and aspect ratio validation. - Widened
ContentType::YouTubeShortaspect ratio bounds to allow square video and updated the English description copy. - Added/updated unit tests covering the new constraints and the YouTube Shorts bound change.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| app/Rules/ContentTypeCompatibleWithMedia.php | Adds numeric media constraint enforcement (counts, GIF, size, duration, aspect ratio) on top of existing type compatibility checks. |
| app/Enums/PostPlatform/ContentType.php | Updates youtube_short aspect ratio bounds to include square and keeps media rules as the shared source of truth. |
| lang/en/posts.php | Updates English UI copy for YouTube Shorts to mention square support. |
| tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php | Adds new tests for the expanded backend constraint checks. |
| tests/Unit/Enums/ContentTypeTest.php | Adds a unit test asserting the widened YouTube Shorts aspect ratio bounds. |
Suppressed comments (2)
app/Rules/ContentTypeCompatibleWithMedia.php:223
- Within
validateItemConstraints(), a single media item can currently trigger multiple$fail()calls (e.g. size + duration + aspect ratio). InerrorsFor()this results in the last failing check winning, which can hide the more relevant constraint violation. Returning immediately after the first failure per item keeps the error stable and avoids accidental overwrites.
if ($this->isVideo($item)) {
$maxVideoBytes = $contentType->maxVideoBytes();
if ($maxVideoBytes && $size > $maxVideoBytes) {
$fail("{$contentType->label()} videos must be under ".self::formatBytes($maxVideoBytes).'.');
app/Rules/ContentTypeCompatibleWithMedia.php:253
validateAspectRatio()currently enforces bounds for any item that hasmeta.width/meta.height, even when the item can't be classified as an image or video (bothtypeandmime_typeare nullable perPostMediaRules). This can incorrectly reject cases like Instagram Story images that should be auto-fit when the stored item is missingtype/mime_type. Restrict aspect-ratio enforcement to items that are actually images or videos.
$width = (float) data_get($item, 'meta.width', 0);
$height = (float) data_get($item, 'meta.height', 0);
if ($width <= 0 || $height <= 0) {
return;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| foreach ($media as $item) { | ||
| $this->validateItemConstraints($contentType, (array) $item, $fail); | ||
| } |
| 'youtube_short' => [ | ||
| 'label' => 'Short', | ||
| 'description' => 'Vertical video up to 3 minutes', | ||
| 'description' => 'Vertical or square video up to 3 minutes', | ||
| ], |
|
Thanks for this, and sorry for the slow review — it turned into a bigger investigation than the PR deserved to trigger. Short version: the direction is right, but we found that enforcing these numbers on the backend surfaces a problem the frontend was hiding. Three of the six aspect-ratio windows don't match what the platforms actually document — Instagram Reels accepts So merging as-is would either do nothing for video, or — once we add a server-side probe — start rejecting Reels that publish fine today. I wrote the whole thing up in #304, including the platform docs for each number and a task-by-task plan. Phase 1 there is essentially this PR's scope, corrected — split the aspect bounds by media type, fix the numbers against the docs, make the crop-aware case work, and the i18n you started in Two things worth calling out that came from your PR and are now in the plan: Would you like to take Phase 1? It's fully specced down to the test code, and your commits stay at the base of it. If you'd rather not, no problem at all — say the word and I'll pick it up and credit you on the PR. |
Summary
Covers goals 2 and 3 from the issue. Goal 1 (server-side video meta
via a probe like ffprobe) is deliberately left out - see below.
Changes
Backend validation (web + API + MCP)
ContentTypeCompatibleWithMediaonly checked media typecompatibility (image/video/document, mixed media, requires-media) even
though
ContentType::mediaRules()already exposes the full numericrule set the Vue editor enforces via
useMediaRules/useMedia(
max_files,min_files, byte caps, max video duration, aspect ratiobounds, GIF support). Scheduling or publishing an over-limit post via
API/MCP silently skipped all of that.
Extended the rule to also check, per platform's
content_typeagainstthe shared post-level media (same model as
usePostComplianceon thefrontend - one platform's failure doesn't invalidate a shared image
that's fine for a different enabled platform):
max_media_count/min_media_count)auto-fit, e.g. Instagram Story)
Single source of truth stays
ContentType::mediaRules()- no numbersduplicated.
On goal 1 (reliable server-measured video meta): duration/width/height
come from client-supplied
media.*.meta, same as the frontend alreadyuses. There's no server-side probe (ffprobe or similar) in this codebase,
and adding one means a new binary/package dependency, which needs a
separate decision. Until then, a missing meta value is treated as
unknown and skipped rather than rejected - identical to the frontend's
existing
?? 0fallback behavior, so this doesn't change what'senforceable today, just closes the API/MCP gap for whatever the client
already sends. Also out of scope: TikTok's account-dynamic max duration
(
creator_info.max_video_post_duration_sec), which the issue itselfflags as needing a deliberate fetch-at-validate-time design.
Square YouTube Shorts
YouTube accepts Shorts that are square or vertical up to 3 minutes;
aspectRatioBounds()only allowed ~9:16 (0.5-0.6) foryoutube_short.Widened to
[0.5, 1.0]so square (1:1) is accepted, and updated thecontent-type description copy.
Test plan
tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php- 12 new tests: file count bounds, GIF rejection, per-type size limits, video duration, aspect ratio (including the youtube_short square case and Instagram Story's auto-fit skip), and confirms missing meta is skipped rather than rejectedtests/Unit/Enums/ContentTypeTest.php- new test for the widened YouTube Shorts boundsphp artisan test --compact tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php tests/Feature/UpdatePostRequestTest.php tests/Feature/Api/PostApiPlatformMetaTest.php tests/Feature/Mcp/PostPlatformMetaToolTest.php tests/Unit/Support/PostMediaRulesTest.php tests/Unit/Enums/ContentTypeTest.php- 80 passed, no regressions in existing type-only checksvendor/bin/pint --dirty --format agent- passedAddresses #200 (goals 2 and 3; goal 1's server-side probe is a separate follow-up needing a dependency decision)