Commit graph

383 commits

Author SHA1 Message Date
Paulo Castellano
f4513efc70 fix: allow unicode filenames in chunked asset uploads
X-File-Name is an HTTP header, so raw en-dashes/emoji blow up fetch() before the request leaves the browser. Percent-encode on the client and rawurldecode on the server.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:46:48 -03:00
Paulo Castellano
a78f5a96ae feat(onboarding): add "where you found us" referral-source step
Adds a single-select referral-source step between the goals and connect
steps of onboarding. The choice is stored on users.referral_source and
mirrored to PostHog, mirroring the existing persona and goals steps.

- ReferralSource enum (12 sources) + nullable users.referral_source column
- referralSource()/storeReferralSource() controller actions with the same
  self-hosted, subscribed, persona and goals guards as the sibling steps
- connect() now requires a referral source before rendering
- Single-select ReferralSource.vue page mirroring the goals step
- Localized across all 15 locales
2026-07-18 16:07:11 -03:00
Paulo Castellano
2c2ab69e68 Merge main + finalize AI brand-colors toggle
Resolves the AiPostWizard conflict and completes the feature:
- i18n parity: brand_colors_label + brand_colors_description in all 15 locales
  (was en/es/pt-BR only, which broke LocalizationParityTest).
- Reworked the two-button toggle into a Switch with an explanatory description
  (matches the settings Switch/card pattern).
- Only shown for templates that honor the flag: added appliesBrandVisuals() to
  the AiContentTemplate contract (ImageCard=true, tweet cards=false), exposed as
  applies_brand_visuals in the create-page DTO, and gated the toggle on it — so
  it no longer appears (as a no-op) for tweet-card styles.
- Tests: TemplateContractTest covers appliesBrandVisuals for all templates.
2026-07-18 15:12:26 -03:00
Paulo Castellano
2c52c90b6e fix(mobile): polish automations, dialogs, workspaces and post views
- automations: minimal back-only header on mobile for the workflow builder
  and detail tabs (extracted AutomationMobileBackHeader); open live
  automations on the metrics tab (drafts still open on workflow); full-width
  status filter + refresh on the invocations toolbar; use IconMenu2 for the
  mobile sidebar trigger
- dialogs: stack DialogFooter primary-on-top / cancel-at-bottom on mobile
- workspaces: bring the workspace picker cards into the neo-brutalist design
- posts: left-align the label filter content; wrap the post-view date/status
  header so a long status badge no longer squeezes the date; add hamburger
  clearance to the editor's mobile tab bar
2026-07-18 10:58:07 -03:00
Paulo Castellano
8866e4ed46 fix(posts): cache the link card as an array, not the DTO
The link-card cache stored a LinkCardMetadata object, which does not round-trip through the Redis cache driver — a cache hit came back as __PHP_Incomplete_Class and 500'd the preview endpoint. Cache the plain array (toArray) and rebuild the DTO via a new fromArray(). Primitives round-trip cleanly through every driver. The cache test now asserts a primitive is stored and a hit reconstructs the DTO; the array cache driver used in tests hid the bug because it never serializes.
2026-07-17 16:34:04 -03:00
Paulo Castellano
925fd85b95 refactor(posts): return the card display domain from the backend
Compute the bare display host once in LinkCardMetadata (via Laravel's Uri::host + Str::chopStart) and return it as card.domain, so the LinkCard component renders it directly instead of parsing the URL client-side. The component is now purely presentational.
2026-07-17 16:24:23 -03:00
Paulo Castellano
4a08913d70 fix(security): make SSRF private-network block configurable and guard the last user-URL fetches
Add config('trypost.security.allow_private_network') (env TRYPOST_ALLOW_PRIVATE_NETWORK, default off) so self-hosted operators can reach their own internal network; only the private-IP rejection is bypassed, scheme/host checks always apply. Add SafeHttpFetcher::guardedRequest() and route the last unguarded user-supplied-URL fetches through it: the Unsplash/Giphy asset import, the API/MCP attach-media-from-URL download, and the OAuth avatar download. Our-own-storage reads (media crop, Bluesky media) are intentionally left unguarded so internal storage keeps working when self-hosted.
2026-07-17 15:40:15 -03:00
Paulo Castellano
11d6bddf9c fix(security): guard automation node requests against redirect-SSRF
RunFetchRssNode, RunWebhookNode and RunHttpRequestNode guarded the initial URL but then followed redirects unguarded, so a public URL could 302 to an internal address. RSS now fetches through SafeHttpFetcher::get() (re-guards every hop); webhooks no longer follow redirects; the generic HTTP request node re-runs the SSRF guard on each hop via a new SafeHttpFetcher::redirectGuardOptions().
2026-07-17 14:55:56 -03:00
Paulo Castellano
8648ed9720 feat(bluesky): add link preview cards for posts
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
2026-07-17 14:32:40 -03:00
Paulo Castellano
c432034dd1 test(ai): guard prompt max on the editor endpoint and the wizard min boundary
- Assert GeneratePostContentRequest rejects a prompt over the shared max, so the
  editor's limit is pinned explicitly (not only implied by the create wizard).
- Assert the create wizard accepts a prompt at exactly the minimum length,
  complementing the below-minimum rejection.
2026-07-17 11:08:50 -03:00
Paulo Castellano
3dbb8e6f7e fix(ai-create): sync the prompt limit across front and back
The counter added earlier drifted from the backend in two ways: it counted
UTF-16 code units over the raw (untrimmed) value, while the backend measures
Unicode characters (mb_strlen) over the trimmed value that is actually sent —
so emoji or trailing whitespace could falsely turn the counter red and block
the button. The 2000 limit was also copied into three places, and the wizard's
frontend `>= 3` minimum had no backend counterpart.

- Add App\Support\AiPromptRules as the single source of truth for the prompt
  bounds; both StartPostCreationRequest and GeneratePostContentRequest use it.
- Add min:3 to the create wizard endpoint so front and back agree (the editor's
  generate-content flow keeps `required` — it has no counter to mirror).
- Count code points over the trimmed value in AiPostWizard so the counter and
  the submit gate match what the backend validates, matching AltTextDialog.
- Cover min/max/boundary in PostAiCreateTest.
2026-07-17 11:03:41 -03:00
Paulo Castellano
2722effba9
Merge branch 'main' into feat/story-blurred-background 2026-07-16 20:34:00 -03:00
Paulo Castellano
2d63de7a97 Address review: dedupe memory-budget logic, fix stale doc, cover crop errors
- Extract estimatedDecodeMemory() + a MAX_DECODE_MEMORY_BYTES constant so optimizeImage (fallback) and the crop/fit guard (throw) share one estimate instead of duplicating the formula and threshold.
- Correct the cropFailureException docblock: it now covers download, crop, and story-fit failures, not just downloads.
- Add a Facebook crop process-failure test and an Instagram cropped-temp-leak test so the crop path matches the fit path's error coverage.
2026-07-16 20:28:21 -03:00
Paulo Castellano
90ce205763 Harden Instagram story image fitting and cover it end to end
- Guard MediaOptimizer::fitToCanvas and cropToAspectRatio against huge-dimension sources (getimagesize budget check) so they fail cleanly instead of exhausting GD memory.
- Fit and crop now translate decode/process failures into a clean InstagramPublishException and remove their temp files via finally; the two paths are symmetric.
- publishStory reads the story canvas dimensions via data_get.
- Previews: restore the IG empty-state background, align the autoFitsImage suppression predicate to isImage, drop the explanatory comment, and use single-quote imports.
- Tests: real end-to-end story fit, undecodable/download/container failures, the memory guard (fit and crop), temp-file cleanup, blurred-background pixel assertions, and the aspect-ratio warning suppression.
2026-07-16 20:09:43 -03:00
Paulo Castellano
949dfb5143 Preserve media alt text when regenerating an AI image
The regenerate job replaced the media item without carrying its meta, silently dropping the user's alt text (and slide metadata) from the persisted post.

Copy meta from the freshly-locked post row (not the pre-render snapshot) inside the transaction, so an alt edit made while the multi-second render runs is kept rather than overwritten.
2026-07-16 16:07:24 -03:00
Paulo Castellano
cf045f2cdb Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 13:55:33 -03:00
Paulo Castellano
f74053b118 Extract alt-text truncation onto MediaItem and close test gaps
Centralize the per-platform mb_substr truncation that every publisher was
repeating into MediaItem::altTextFor(Platform), delete each publisher's
private altFor() helper, and clarify the Platform::altTextMaxLength()
docblock so it doesn't imply Instagram's documented 1000-char cap is a
guess. Add the assertions review flagged as missing: LinkedInPage/
InstagramFacebook alt-text caps, non-string and literal-"0" alt_text
normalization, altTextFor() truncation/unsupported-platform behavior,
Mastodon's no-description-part case, and the public API's accept/reject
path for media.*.meta.alt_text.
2026-07-11 10:34:57 -03:00
Paulo Castellano
f12f525d54 Publish per-image alt text to Discord attachments 2026-07-11 09:34:57 -03:00
Paulo Castellano
6115362c07 Read Pinterest pin alt text from the image, not per-platform meta 2026-07-11 09:29:39 -03:00
Paulo Castellano
b1162cb67a Publish per-image alt text to Threads containers 2026-07-11 09:25:07 -03:00
Paulo Castellano
9a86aba235 Publish per-image alt text to Instagram image containers 2026-07-11 09:18:58 -03:00
Paulo Castellano
2c81416bd0 Publish per-image alt text to Facebook photos 2026-07-11 09:13:05 -03:00
Paulo Castellano
941e517b2e Publish per-image alt text to LinkedIn images and carousels 2026-07-11 09:08:15 -03:00
Paulo Castellano
65b5007784 Publish per-image alt text to Mastodon media description 2026-07-11 09:02:26 -03:00
Paulo Castellano
4beced64a8 Publish per-image alt text to X via media metadata 2026-07-11 08:56:42 -03:00
Paulo Castellano
555957de61 Publish per-image alt text to Bluesky image embeds 2026-07-11 08:50:13 -03:00
Paulo Castellano
521cfca724 Validate per-image media alt_text (max 2000) in PostMediaRules 2026-07-11 08:46:09 -03:00
Paulo Castellano
f36881cb6a Fail loud on a missing coupon and block unpaid extra workspaces
Two hardening fixes for the paid first month:

- FirstMonthCheckoutDiscount throws when the paid first month is enabled
  but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging
  every new customer the full price with no discount.
- Guard workspace store() with the same active-subscription check create()
  already applies, so a direct POST can't bootstrap a second billable
  workspace and inflate checkout quantity past the fixed first-month coupon.
2026-07-09 15:37:25 -03:00
Paulo Castellano
c74170a25a
Merge branch 'main' into feat/story-blurred-background 2026-07-07 11:13:59 -03:00
Paulo Castellano
b7773849ec Save the autofilled site logo as the workspace logo on brand settings update
Brand autofill on the workspace settings page already captured the site logo
and rendered a preview beneath the URL, but the update flow never persisted it.
The store flow attached it via LogoAttacher; the update flow was missing all
three legs: the form field, the request rule, and the controller attach.

- BrandTab: add logo_url to the useForm payload so autofill can set it and the
  form submits it.
- UpdateWorkspaceRequest: validate logo_url (nullable url) — FormRequest strips
  any unvalidated key, so without a rule it was silently dropped.
- WorkspaceController::updateSettings: pull logo_url out of the validated data
  (it is not a column) and attach it through LogoAttacher, mirroring store.
2026-07-03 20:05:54 -03:00
Paulo Castellano
56d7fa4033
Merge branch 'main' into feat/content_languages 2026-07-03 19:38:37 -03:00
Paulo Castellano
de4d5f3c68 Share a ContentLanguageOption type and close the review's test gaps
- Extract ContentLanguageOption into @/types and use it for availableContentLanguages
  across BrandForm, BrandTab, Brand, Create, and the LanguagePicker options, so the
  englishName field that drives search is visible to TypeScript instead of being
  dropped silently by the pass-through prop types.
- Add BrandAnalyzerTest: assert the language schema enum equals the full 15-language
  set (guards against it shrinking back to a hardcoded subset behind ::fake()) and
  that instructions() lists every code.
- Isolate the "LLM language wins" autofill test by declaring the page as `en` while
  the LLM returns `de`, so it actually proves mergeLlm precedence instead of both
  paths agreeing.
- Cover SetLocale's cookie side effect: the default locale cookie is set on an
  invalid/absent cookie and left untouched for a valid locale.
2026-07-03 19:28:28 -03:00
Paulo Castellano
c7be1af124 Detect all 15 content languages on autofill, harden RTL/i18n, and cover the gaps with tests
- Brand-analyzer prompt now lists every supported language instead of only
  en/pt-BR/es, so onboarding autofill can detect the 12 added languages. The
  backtick-formatted list is built in BrandAnalyzer::instructions(), keeping the
  Blade clean and the enum free of prompt presentation.
- Translate the delete-confirmation keyword for el/ja/zh/ar (the four locales
  that still shipped the English "delete").
- Make the language and font comboboxes RTL-correct (logical ms-* instead of
  physical ml-*), and let the language combobox be searched by English name via
  a visually-hidden label (ContentLanguage::options() now exposes englishName).
- Correct the ContentLanguage class docblock: the enum is also the source of
  truth for the UI locales' text direction.

Tests: SetLocale middleware dir/RTL, isRtl and the full 15-language
englishName/label match arms, LLM language detection beyond en/es/pt-BR, and
store-path persistence of a non-default content language plus rejection of an
unsupported one.
2026-07-03 18:52:45 -03:00
Paulo Castellano
c59adb4871 Cover every supported language in ContentLanguage detection tests
Extend the homepage-language detection dataset to assert all 15 supported
languages resolve from their <html lang> subtag, and pin the primary-subtag
matching so a malformed tag ("english") no longer resolves via a two-letter
prefix.
2026-07-03 15:39:35 -03:00
Paulo Castellano
85120ac8f3 Add UI localization infrastructure and RTL support for 12 new locales
Register the 12 additional languages (fr, de, it, nl, pl, el, ja, ko, zh,
ru, tr, ar) as available UI locales so the language switcher and the API
accept them, keeping the set in lockstep with the ContentLanguage enum.

- config/languages.php lists all 15 UI locales with their native names.
- ContentLanguage::isRtl() drives the document `dir`; SetLocale shares it
  to the Blade root and HandleInertiaRequests shares it to Inertia, and
  app.ts mirrors it on SPA navigations so RTL locales lay out correctly.
- dayjs imports the 12 new locales so dates localize instead of falling
  back to English.
- A LocalizationParityTest guards against key drift: every locale must
  ship every base translation file with exactly the keys of lang/en.
2026-07-03 15:38:40 -03:00
Paulo Castellano
f7ef13c116 Centralize supported content languages in a ContentLanguage enum
The new content-language options were hand-duplicated across request
validation, the UI picker, and homepage detection, while the brand
analyzer's structured-output enum and the AI image prompt's language
name still only knew about en/pt-BR/es. That left autofill unable to
detect the new languages and made image text fall back to English for
them.

Introduce App\Enums\Workspace\ContentLanguage as the single source of
truth and derive every site from it:

- Store/UpdateWorkspaceRequest validate against ContentLanguage::values()
- BrandAnalyzer's language enum uses ContentLanguage::values()
- AiImageClient::languageName() resolves via the enum's englishName()
- HomepageMetaExtractor detects through ContentLanguage::fromHtmlLang()
- BrandForm consumes availableContentLanguages from the backend, like
  availableFonts/availableImageStyles, instead of a hardcoded list

Also fix two labels: nl "Nederlandse" -> "Nederlands", zh -> "中文".
2026-07-03 14:12:25 -03:00
Paulo Castellano
ba8068f517 Recover from a lost-rotation race on the post-refresh verify too
refreshThenVerify only guarded refreshToken(); the verify that followed was
outside the try, so the sub-commit window where a lock-skipped refresh reloads a
not-yet-persisted token surfaced as a 401 there and falsely disconnected a
still-usable single-use-refresh_token account (X/LinkedIn). The refresh and the
verify that follows it now share one recovery: on a TokenExpiredException,
reload and, if a concurrent refresh has since persisted a fresh access_token,
verify with it instead of giving up.
2026-07-03 11:39:48 -03:00
Paulo Castellano
83ba72ddc6 Stop Meta rate-limit errors from disconnecting Instagram/Threads tokens
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.

Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
2026-07-03 11:07:54 -03:00
Paulo Castellano
545a779848 Never leave Instagram/Threads with a null token expiry
A null token_expires_at drops an account from every refresh path (the
cron's whereNotNull filter and the is_token_expired / is_token_expiring_soon
checks all treat null as "nothing to do"), so the token silently lapses.
Threads could persist null two ways: the long-lived exchange failing at
connect (kept the ~1h short-lived token) — now fails the connect instead;
and a refresh response omitting expires_in — now defaults to 60 days for
both Instagram and Threads, matching the X refresh convention.
2026-07-03 10:33:26 -03:00
Paulo Castellano
94d0156ec0 Give Instagram/Threads a wider proactive-refresh window
Extension-model tokens (Instagram/Threads) can't be refreshed once they
expire, so the shared 30-minute cron window left only a ~15-minute buffer
against queue backlog on the default queue — and a lapse forces a full
reconnect. Rotating platforms go through verify() and won't rotate a
still-valid token, so they keep the tight 30-minute window; extension
platforms now get a 24-hour lead via a per-model query.
2026-07-03 10:33:26 -03:00
Paulo Castellano
6037169aa9 Cover Threads token extension with an explicit test
The extend-while-valid path is shared by Instagram and Threads via
Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the
Instagram job test so both extension-model platforms are locked down.

Refs #126
2026-07-03 09:58:26 -03:00
Paulo Castellano
1bb67b7abb Keep Instagram/Threads tokens extended while still valid
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).

Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
  OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
  valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().

Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.

Refs #126
2026-07-03 09:18:45 -03:00
Paulo Castellano
3dd1804c8e Stop publishers/analytics from rotating still-valid tokens
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.

Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.

- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
  anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
  the expired-token path are unchanged.

Refs #126
2026-07-03 08:30:58 -03:00
Paulo Castellano
2f4b974130 Fix X token chain breaking from over-rotation
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:

- The proactive refresh job called refreshToken() directly, bypassing the
  access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
  2h access-token lifetime — so every X account was rotated every hour even
  while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
  whether a concurrent refresh had already persisted a working token.

Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
  only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
  window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
  verify with a concurrently-refreshed token before marking TokenExpired.

Refs #126
2026-07-02 20:32:55 -03:00
Paulo Castellano
a4792300cc
Merge branch 'main' into fix/workspace-content-language-locale 2026-07-02 15:56:45 -03:00
Paulo Castellano
974c076e86 fix(tracking): fire begin_checkout when onboarding checkout starts
trackBeginCheckout was defined but never called, so the begin_checkout
(GTM/dataLayer) and checkout.started (PostHog) events never fired. Wire
it into the onboarding Connect submit handler, before the redirect to
Stripe, and pass the workspace plan from the controller so the event
carries plan name + interval.
2026-07-02 09:21:31 -03:00
Paulo Sérgio Dantas
7dab106f06 fix(workspace): default content_language to the app locale, not 'en'
When a workspace is created without an explicit content_language,
`data_get($data, 'content_language')` returns null, array_filter drops the key,
and the column falls back to its DB default of 'en'. So AI content and
notifications came out in English even when the app was running in another
locale.

Default to `app()->getLocale()` instead, so a new workspace inherits the user's
current language. An explicit content_language still wins.
2026-07-01 13:49:48 -03:00
Paulo Sérgio Dantas
67fb40d177 feat(ai-create): let users choose brand colors or free AI colors for images
The image pipeline already threads `applyBrandVisuals` through
`TemplateContext` -> `PostImagePipeline` -> `TemplateImageGenerator`, but it was
hardcoded to `true` at the dispatch site, so generated images always used the
workspace brand palette with no way to opt out.

Expose the choice in the create wizard: a "Brand colors" / "Let AI decide"
toggle (shown only when images are generated). The flag flows
front -> `StartPostCreationRequest` (`apply_brand_visuals`) ->
`PostAiCreateController@start` -> `StreamPostCreation` -> `TemplateContext`,
defaulting to `true` so existing behavior is unchanged when the field is absent.
2026-07-01 01:47:57 -03:00
Paulo Castellano
bfbcd642ea feat(instagram): auto-fit story images to 9:16 with blurred background
Story images that aren't 9:16 were clipped by Instagram. They are now
fitted onto a 1080x1920 canvas — the image is contained and a blurred,
darkened copy of itself fills the letterbox gaps, so nothing is cropped
and the background color adapts to the image.

The fit happens at publish time (the hosted copy lives in social-crops/),
and the post editor preview now renders the same blurred-background fit
for stories, so the user sees exactly what will publish. The aspect-ratio
warning is suppressed for story images since they're auto-fitted.

Instagram only — Facebook stories are video-only in our flow.
2026-06-29 14:35:36 -03:00
Paulo Castellano
a63e9ea778 fix(media): treat any download failure (timeout/DNS/refused) as a failed fetch
MediaAttacher::download() only caught RuntimeException, but a connection-level
failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not
RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the
API hosting path that propagated as a 500 (not the promised 422), skipped the
batch rollback (orphaning an already-hosted item), and leaked the temp file.

Catch Throwable so any fetch failure returns null → the caller rejects cleanly
with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths
(a timeout there now reports a failed URL instead of 500).
2026-06-29 13:41:47 -03:00