Commit graph

11 commits

Author SHA1 Message Date
Paulo Castellano
6496588bbc
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).

Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.

The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.

The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.

Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.

Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 15:31:05 -03:00
Paulo Castellano
317788eb37 fix(social): apply chunked-upload memory fix to LinkedIn and YouTube
The same leak audited in the X publisher exists wherever a video is streamed
through a per-chunk request loop. Reproduced with the reported 49MB file:
LinkedIn's plain Http::withToken() chunk loop grew to ~146MB (and it allows
videos up to 5GB). Centralize the fix as freeChunkMemory() on the shared
trait and call it (after unset) in the X, LinkedIn and YouTube loops so memory
stays flat.

Audited and left alone: TikTok and Instagram pull from a video_url (no local
upload); Facebook and Bluesky stream the whole file in a single request.

Adds a LinkedIn multi-chunk video test.
2026-06-25 21:38:52 -03:00
Paulo Castellano
5b065ce121 fix(x): stop chunked video upload from exhausting memory
The APPEND loop sent each 1MB segment without releasing it: the chunk plus
its multipart copies and the Guzzle/retry object graph were kept alive by
reference cycles the GC doesn't reclaim mid-loop, so memory grew ~4MB per
segment. A 49MB video peaked at ~242MB locally and crossed the 512MB limit
in production. Freeing the segment and forcing cycle collection each
iteration keeps memory flat (~50MB regardless of video size).

Also harden socialHttp()'s retry predicate: it read $exception->response
directly, which fatals on exceptions without a response (e.g. a
ConnectionException during a long upload), masking the real error.
2026-06-25 21:26:15 -03:00
Paulo Castellano
cf80e1fcae refactor(social): single source of truth for token redaction
Same shape of bug as the refreshToken duplication: the regex that strips
access_token / Bearer headers from logged HTTP bodies existed in three
near-identical copies (HasSocialHttpClient trait, TokenRefreshClient,
SocialPublishException), drifting subtly — SocialPublishException was
missing the JSON "token" pattern.

Extracts a TokenRedactor::redact(string) helper and routes all callers
through it. Adding a new token format now means one regex in one file.
2026-05-19 09:21:43 -03:00
Paulo Castellano
fcb70b3599 refactor(social): single source of truth for token refresh
Every per-platform publisher and analytics class had its own private
refreshToken() implementation, all doing essentially the same thing as
ConnectionVerifier's per-platform refresh*Token methods. ~17 copies of
near-identical OAuth refresh logic across the codebase, which is how
the 5xx→TokenExpired bug got planted in the publish path even after we
fixed it on the hourly/daily jobs.

Consolidation:
- All publish/analytics paths call app(ConnectionVerifier::class)
  ->refreshToken($account) instead of their own implementation.
- 17 private refreshToken() methods deleted.
- refreshTokenWithLock helper removed from HasSocialHttpClient trait
  (its lock semantics are duplicated by ConnectionVerifier's per-
  account lock).
- ConnectionVerifier::refreshLinkedInToken passes $account->platform
  to TokenRefreshClient so the "LinkedIn" vs "LinkedIn Page" label
  in error messages is preserved.
- TokenRefreshClient now treats HTTP 429 the same as 5xx (raises
  PlatformUnavailableException) — replaces the retry-on-429 behavior
  that socialHttp() provided to the deleted refresh methods.

Net: -460 LOC. Single per-platform refresh implementation. Every fix
or new platform now lands in exactly one place.
2026-05-19 09:18:58 -03:00
Paulo Castellano
b10f25f94d refactor(posts): tighten content-length caps and simplify contentOverflow
- Platform::contentOverflow returns int (0 = fits) via max(), drops the
  nullable-int + ternary pattern; HasSocialHttpClient and the validation
  rule updated to compare against 0 instead of null.
- Drop the hardcoded 63206-char limit to 10000 across all entry points
  (Platform enum, MCP CreatePostTool/UpdatePostTool, FacebookRules, all
  three FormRequests). Facebook's API accepts up to 63206 but nobody
  writes 63k-char posts and emoji-heavy content risks overflowing the
  TEXT column's 65535-byte ceiling.
- Compiled i18n JSON regenerated with the content_exceeds_platform key.
2026-05-11 20:06:44 -03:00
Paulo Castellano
953be22b5b fix(posts): block scheduling when content exceeds any platform's char limit
Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.

- Add `Platform::contentOverflow()` as the single source of truth and reuse it
  from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
  `App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
  via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
  the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
  offending platform in `postActionTooltip` using the existing
  `getPlatformLabel` resolver.
2026-05-11 19:39:41 -03:00
Paulo Castellano
0f6ae9a4e6 feat: add PostCommentCreated broadcast event 2026-04-15 20:11:36 -03:00
Paulo Castellano
a9184c9401 fix: withoutOverlapping on schedules, publisher token refresh lock, PublishPost tries:1, TokenExpired allows publish, API validation scoping 2026-04-01 12:49:01 -03:00
Paulo Castellano
e034c0b572 feat: publishing hardening — content sanitization, validation, scopes, stuck recovery
- Add ContentSanitizer: strips HTML, converts bold/underline to Unicode for LinkedIn
- Add backend content length validation in all 11 publishers via HasSocialHttpClient trait
- Add scope verification before publishing — checks required scopes per platform
- Add RecoverStuckPosts command (every 30min) — recovers posts stuck in publishing > 1h
- Add requiredPublishScopes() to Platform enum
- Update SocialAccount factory with correct scopes per platform state
2026-04-01 11:25:25 -03:00
Paulo Castellano
c48c774e23 feat: publishing engine improvements — rate limit retry, inline token refresh, per-platform queues, proactive refresh
- Add HasSocialHttpClient trait with 429 rate limit retry (3 attempts, 5s delay)
- Integrate trait into all 10 publishers (YouTube uses Google SDK)
- Add inline token refresh retry in PublishToSocialPlatform job
- Add per-platform Horizon queues via Platform::queue() and Platform::allQueues()
- Add RefreshExpiringTokens hourly command for proactive token refresh
- Fix token leaks: redact response bodies in all Log::error calls
- Fix token leaks: remove $response->body() from exception messages
- Fix ConnectionVerifier: redact all refresh error logs
- Fix null checks on API response IDs (Instagram, Threads, Pinterest, Facebook)
- Fix PublishPost::failed() to mark post as failed
- Fix StoreChunkedMediaRequest: validate max 1GB total size
- Fix scheduled_at validation: string → date
- Fix StoreMediaRequest: images max 10MB, videos max 1GB, only MP4 video
2026-04-01 10:51:53 -03:00