Commit graph

476 commits

Author SHA1 Message Date
Paulo Castellano
ff0bfa8a06 Expose Pinterest board IDs via MCP and REST API.
Agents need board_id to publish pins; list boards per connected account so create/update can set platforms[].meta.board_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 21:47:51 -03:00
Paulo Castellano
3d4a42c7b0 Share content-type video duration caps to the frontend via Inertia.
Keep ContentType as the single source of truth and stop hardcoding maxVideoDurationSec in useMediaRules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 21:36:53 -03:00
Paulo Castellano
3175fbe8c2 Centralize MCP upload size caps on trypost.media.
Drop the separate ai.mcp.upload.max_size_mb default and reuse Media\Type limits so MCP matches web/API (1GB video ceiling with per-type enforcement).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 21:29:41 -03:00
Paulo Castellano
564f157e44 Fix MCP upload rate limits and Instagram Reel duration caps.
Key signed uploads by workspace so ChatGPT's shared egress IPs don't throttle tenants together, raise the MCP upload cap to 300MB, and expose accurate Reel max durations via API/MCP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 21:21:39 -03:00
Paulo Castellano
d1d38865ef Dispatch PostCreated from PostObserver on every create.
Centralize provenance/broadcast/PostHog triggers so CreatePost and DuplicatePost no longer fire the event by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 12:01:01 -03:00
Paulo Castellano
8b1ce057c3 Fire PostCreated on duplicate and drop unused PostHog eager load.
Duplicates now sync usage and track post.created like CreatePost; TrackPost only eager-loads what capture needs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:56:44 -03:00
Paulo Castellano
2c50a60790 Capture post.created to PostHog with created_via.
Dispatch TrackPost from PostCreated so we can chart which entry points create the most posts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:44:47 -03:00
Paulo Castellano
227ee45c2b Simplify created_via persistence to a plain data_get.
Callers already pass a CreatedVia enum or omit it; drop the defensive resolver.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:38:22 -03:00
Paulo Castellano
588891767b Leave created_via null when omitted or invalid.
Provenance is optional analytics — only persist a CreatedVia value when callers set a valid one.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:36:02 -03:00
Paulo Castellano
a545d29cfb Treat created_via as non-blocking analytics data.
Default to web when the value is missing, null, or invalid so post creation never fails over provenance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:34:24 -03:00
Paulo Castellano
459f4dd5a5 Harden created_via: require it, cover duplicate and all entry points.
Reject CreatePost calls without CreatedVia, set Web on DuplicatePost, and assert wiring for templates, AI, automation, and API spoof attempts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:30:15 -03:00
Paulo Castellano
06f83a6571 Track post creation origin via created_via.
Persist whether a post was created through web, MCP, API, or automation so we can attribute entry points without guessing from request context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 11:19:01 -03:00
Paulo Castellano
38d74e358d fix(x): poll official media upload STATUS endpoint
Use GET /2/media/upload?media_id=&command=STATUS per X API docs, and persist
redacted raw_response on SocialPublishException failures for supportability.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:43:32 -03:00
Paulo Castellano
35e7b703b6 fix(x): harden chunked media upload handling and coverage
Fail hard when media processing fails, map media-specific X invalid-request
errors clearly, and cover GIF/large-image/amplify/finalize/append failure paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:30:34 -03:00
Paulo Castellano
8f4d33cc11 fix(x): send JSON body on chunked media upload finalize
X rejects empty finalize requests with "Request body must be a JSON object",
breaking video and large-media posts that use the chunked upload flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:25:40 -03:00
Paulo Castellano
8964dda8f2 fix: raise YouTube Shorts max duration to 3 minutes
YouTube Shorts now allow videos up to 3 minutes. The dashboard media rules still capped uploads at 60s (API/MCP already had no duration gate), so align the frontend validation, content-type copy, and unit test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:06:15 -03:00
Paulo Castellano
246022e9bc test: cover ChunkedAssetReceiver and ChunkReceipt paths
Adds dedicated tests for local assemble, multipart complete/progress, MediaResource response shape, and orphan R2 cleanup when media registration fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:51:13 -03:00
Paulo Castellano
08ce75d2ec refactor: harden chunked cloud uploads after full PR review
Fix real edge cases: enforce 5MiB non-final parts, track offsets for ordered/idempotent chunks, size from bytes received, delete orphaned R2 objects if Media create fails, and split the controller into clearer paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:16:39 -03:00
Paulo Castellano
3c59dce349 test: cover chunked uploads across local, public, and S3 disks
Dataset for multipart strategy selection plus HTTP paths for assemble (local/public/images) and multipart (s3/r2/spaces videos).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:09:44 -03:00
Paulo Castellano
d4be085642 refactor: clarify multipart is an object-storage fast path, not a disk limit
Local/public already used the assemble-then-store flow. Rename supports() so it is obvious every filesystem disk works; multipart only kicks in for S3-compatible remote disks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:07:11 -03:00
Paulo Castellano
cd79554cfb fix: multipart chunked video uploads directly to R2
The last chunk was reassembling the full file locally and pushing ~185MB to R2 in one request (53s+). Videos/PDFs now upload each chunk as an S3 multipart part so finalize only completes the upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:04:52 -03:00
Paulo Castellano
005d3c9dd6 fix: stream chunked video finalize to object storage
Unicode filename encoding was correct, but large videos still died on the last chunk: the whole file was loaded into memory and uploaded to R2 via Guzzle within PHP-FPM's 30s limit. Stream non-images with writeStream and lift the time limit on finalize.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:55:57 -03:00
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
f9a1731073 fix(auth): stop AuthSplitLayout overflowing on mobile with wide content
The mobile grid column was implicit (auto), so it sized to content — a long
workspace name (truncate = nowrap, wide intrinsic size) stretched the form
column to its max-w-lg (512px) and overflowed a 375px viewport by ~31px on the
workspaces list. Make the column an explicit minmax(0,1fr) track and give the
grid item min-w-0 so it stays within the viewport and the name truncates.

Found by a runtime horizontal-overflow check; adds MobileOverflowTest guarding
it across the layout variants (AuthLayout, default, full-width editor, detail).
2026-07-17 20:00:13 -03:00
Paulo Castellano
5118640a9c feat(automations): mobile-safe builder with desktop notice
The node builder is gated to lg+ (its fixed side panels no longer overflow a phone); below lg it shows a 'works best on a larger screen' notice. Automation tabs scroll instead of cutting off.
2026-07-17 18:38:20 -03:00
Paulo Castellano
ccf888258d feat(assets): touch-friendly gallery card actions on mobile
Uploads/Unsplash/Giphy card actions are visible on touch instead of hover-only, without the dark scrim on small screens.
2026-07-17 18:38:20 -03:00
Paulo Castellano
b7b3809790 feat(posts): make the post editor usable on mobile
- Top switcher (Compose / Channels / Preview / Comments) reveals the panel that was hidden below lg
- Sticky bottom action bar puts schedule/publish/delete within thumb reach
- Composer media actions and comment toolbar are visible on touch (no hover needed); media grid is 3-up
- Clamp media picker and emoji picker widths to the viewport
2026-07-17 18:38:20 -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
9bb1f266e9 fix(security): re-validate SSRF on every redirect hop in SafeHttpFetcher
The shared fetcher guarded only the initial URL, then followed redirects without re-checking each hop, so a public page could 302 to an internal address. Follow redirects manually and run the SSRF guard on every hop; throw when the redirect cap is exceeded. Also hardens brand autofill and logo downloads.
2026-07-17 14:32:40 -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
af9736642c Render the story background as a soft, lightened blur
- Rewrite fitToCanvas to build the blurred story background with Imagick: scale the image to fill the width, heavily gaussian-blur it so shapes dissolve into a colour wash, gamma-lighten it, and mirror the top half onto the bottom for a symmetric background; the foreground is contained (fills the width, never cropped). Falls back to a GD downscale-blur on hosts without ext-imagick.
- Clean up the fit temp file if the blur/encode step throws.
- Update the editor preview (VerticalMediaCanvas) to a matching mirrored, lightened blur so it tracks the publish output.
- Cover the lightened image-derived background, the vertical mirror, and the GD fallback path with unit tests.
2026-07-17 10:31:35 -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
b5dfc37022 Show alt text inside the image lightbox (opt-in) 2026-07-11 13:09:34 -03:00
Paulo Castellano
6b39616be2 Cover the composer alt-text round-trip with a browser test 2026-07-11 10:52:26 -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