Commit graph

1170 commits

Author SHA1 Message Date
Paulo Castellano
f5a9e40bc1 refactor(ui): mobile polish and dead-state cleanup from review
- Restore desktop tab spacing (lg:gap-6) on the automation tabs
- break-words / viewport width caps on the AI review chips, lightbox alt overlay, and pick-time popover
- Remove the now-unused hoveredCommentId state after the comment toolbar became touch-visible
2026-07-17 19:15:58 -03:00
Paulo Castellano
a4ed3bcdad fix(ui): mobile fixes from second-pass review
- NotificationBell panel was fixed at left-[17rem] w-[22rem] and ran off-screen on a phone; now full-width-with-margins below sm
- Reserve top space for the floating sidebar trigger so page headings no longer sit under the hamburger on mobile
- automations/Index header stacks on mobile (was the one index page the first pass missed)
- Contain the preview phone mockup horizontally so it never breaks the page on narrow screens
- Comment bodies wrap long unbroken tokens (break-words)
2026-07-17 19:15:58 -03:00
Paulo Castellano
15eef28161 fix(ui): mobile polish — responsive headings, menu placement, truncation
PageHeader downscales on phones; workspace switcher opens within the viewport; OAuth page-selects go icon-only + truncate; invite info rows truncate.
2026-07-17 18:38:21 -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
49282d2acb fix(ui): stack page headers and toolbars on mobile
Non-wrapping justify-between headers now stack below sm; fixed-width searches go full-width. Calendar header wraps and seeds isMobile at setup to avoid the month-grid flash.
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
a92a87ac46 fix(ui): make tabs scrollable and clamp dialogs/trigger for mobile
- TabsList scrolls horizontally instead of overflowing the page
- DialogContent caps height with internal scroll; DialogScrollContent gets the mobile width gutter
- SidebarTrigger tap target bumped to 36px
2026-07-17 18:38:20 -03:00
Paulo Castellano
5f0346951d
Merge pull request #185 from trypostit/fix/bluesky-link-preview-card
Link preview cards for Bluesky, X and Threads
2026-07-17 16:37:16 -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
7c79b331f6 refactor(posts): drop the frontend URL trim, rely on the backend's clean uri
The link-preview endpoint re-runs UrlDetector on whatever it receives and returns the exact, trimmed URL as card.uri, so the composer only needs a rough match to detect and dedup a link. Removes the firstUrl helper's punctuation/paren trimming that mirrored the backend tokenizer, leaving one trivial regex.
2026-07-17 16:17:06 -03:00
Paulo Castellano
ec3f251401 refactor(posts): simplify the link-preview composable and card
Replace the hand-rolled debounce + watch + lastAttemptedUrl bookkeeping in useLinkCard with vueuse's watchDebounced over a computed URL — the watch only fires when the detected URL changes, so dedup and unmount cleanup come for free. Make firstUrl declarative (regex match + a single trim helper) and drop the www-strip regex in LinkCard for a plain startsWith/slice. Behavior unchanged: URL detection still mirrors the backend UrlDetector (verified against the same cases).
2026-07-17 15:57:51 -03:00
Paulo Castellano
2815a45de8 refactor(security): make the redirect-loop bound explicit in the for header
The manual redirect loop used an empty-condition for(;;) with the bound enforced only by an internal break; termination was correct but read like a mistake. Put the MAX_REDIRECTS bound in the loop header. No behavior change — the internal break still fires first.
2026-07-17 15:49:12 -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
f84c6f818a feat(posts): show link preview card in the X and Threads editors
X and Threads auto-generate link cards server-side from the URL's meta tags, so no publish-side work is needed. Reuse the shared useLinkCard composable to render the card in their editor previews, gated on no attached media.
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
450b6fd3de
Merge pull request #127 from dantaspaulo/feat/ai-prompt-char-counter
feat(ai-create): show character counter under the AI prompt field
2026-07-17 11:29:59 -03:00
Paulo Castellano
e7f93f4aef refactor(ai-create): clarify prompt-rule naming and label the counter
- Rename AiPromptRules::promptRule() to wizardPromptRule() so the asymmetry is
  explicit: only the create wizard carries a minimum; the editor's generation
  reuses just the shared maximum.
- Add aria-live and a data-testid to the prompt counter so the over-limit state
  is announced to assistive tech and reachable from browser tests.
2026-07-17 11:26:15 -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
a9628b7e8d
Merge branch 'main' into feat/ai-prompt-char-counter 2026-07-17 10:46:44 -03:00
Paulo Castellano
67cf43a5f2
Merge pull request #123 from trypostit/feat/story-blurred-background
feat(instagram): auto-fit story images to 9:16 with blurred background
2026-07-17 10:45:28 -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
e1acdab2e3 Revert Facebook and YouTube preview refactors (video-only, no feature effect)
These previews are video-only, so routing them through VerticalMediaCanvas changed nothing; keep the shared canvas on Instagram (Story) and TikTok (Photo) where it actually fits off-ratio images.
2026-07-16 20:51:07 -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
3e8b351872
Merge pull request #179 from trypostit/feat/media-alt-text
Add per-image alt text for accessibility across 9 platforms
2026-07-16 16:12:16 -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
a1f2fea1bc Merge the alt-text setter into the save handler 2026-07-13 08:54:25 -03:00
Paulo Castellano
b5dfc37022 Show alt text inside the image lightbox (opt-in) 2026-07-11 13:09:34 -03:00
Paulo Castellano
e61d12ea94 Show only the alt text on the thumbnail, without the icon 2026-07-11 13:01:02 -03:00
Paulo Castellano
797750a1c8 Show the alt text on the thumbnail and drop the file-size badge 2026-07-11 12:49:56 -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
3397b829c2 Show the alt-text length limit in the editor dialog 2026-07-11 10:49:31 -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
0e93d7e5e3 Normalize alt-text null checks across publishers
Use an explicit `!== null` guard in the Facebook, Instagram, Threads, and
Discord publishers instead of a truthy `if ($alt = ...)`, matching the
Pinterest/X/Mastodon style. The truthy form dropped a literal "0" alt; the
explicit check treats every non-null description uniformly. Also add the
missing PHPDoc to Platform::supportsAltText().
2026-07-11 10:02:50 -03:00
Paulo Castellano
8d7948cb2e Add per-image alt text editor to the post composer 2026-07-11 09:45:52 -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