diff --git a/.env.example b/.env.example index 6a65d466..8b76d274 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ SELF_HOSTED=true # Allow more than one connected account per social network in a workspace. # Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true. ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true + +# Rewrite links in the X version of a post as non-clickable (example(.)com), so +# X does not bill them at the link-post rate. Self-hosted installs publish through +# their own X app and pay their own bill, so set true only if you want it. +X_DEFUSE_LINKS=false META_PAGE_WALK_SECONDS=20 # Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so diff --git a/AGENTS.md b/AGENTS.md index 91027dd8..9ffbdbdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,3 +267,22 @@ ## Social Platform API Documentation (official sources) - **Bluesky / AT Protocol**: official lexicons — https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto/repo ; HTTP API reference — https://docs.bsky.app - **Discord**: Webhook resource (used for our webhook-based publishing) — https://docs.discord.com/developers/resources/webhook - **Telegram**: Bot API — https://core.telegram.org/bots/api + +## X link defusing (env knob) + +X bills a post containing a URL at **$0.20** vs **$0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post`. + +| Env | Config | Default | Effect | +| --- | --- | --- | --- | +| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true`: URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)`). `false`: the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. | + +Standing constraints: +- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()`. Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer`, the app/API/MCP previews show the defused text for free. +- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br`, which X still detects and bills. +- A URL carrying `https://`, `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js`, and it goes through `App\Support\LinkTlds`, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too. +- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to. +- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step. +- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default. +- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor. +- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead. + diff --git a/CLAUDE.md b/CLAUDE.md index 06924478..5d1d1977 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -411,6 +411,24 @@ ## TryPost.it Documentation - All our documentation to final user it's under https://docs.trypost.it +## X link defusing (env knob) + +X bills a post containing a URL at **$0.20** vs **$0.015** for a plain post (13x), and its algorithm demotes link posts. So on Cloud the `ContentSanitizer` rewrites every URL in the X version of a post into a non-clickable form — `https://example.com/post` becomes `example(.)com/post`. + +| Env | Config | Default | Effect | +| --- | --- | --- | --- | +| `X_DEFUSE_LINKS` | `trypost.platforms.x.defuse_links` | `false` | `true`: URLs in the X version of a post are rewritten non-clickable (scheme and `www.` dropped, **every** dot of the host replaced with `(.)`). `false`: the X content is published unchanged. Only affects `Platform::X` — every other network keeps the URL intact. | + +Standing constraints: +- The transform lives in ONE place: the `Platform::X` arm of `App\Services\Social\ContentSanitizer::sanitize()`. Never re-implement it in a publisher or add a `$defuseLinks` parameter to `sanitize()` — a per-call-site flag gets forgotten at the next entry point and we silently start paying again. Because `PostPreviewer` also goes through `ContentSanitizer`, the app/API/MCP previews show the defused text for free. +- **Every** dot of the host must be broken. Defusing only the dot before the TLD leaves `blog.example.com` in `blog.example.com(.)br`, which X still detects and bills. +- A URL carrying `https://`, `http://` or `www.` is defused on sight. A **bare** host is only a link when its last label is a delegated TLD — that check is the one thing separating `acme.com` from `Node.js`, and it goes through `App\Support\LinkTlds`, which mirrors the full IANA root zone rather than a hand-picked subset. Never replace it with "any 2+ letters after a dot", and never trim it back to a curated list: whatever X links is what X bills, so the two must stay in step. `README.md` and `backup.zip` are defused on purpose — `.md` and `.zip` are real TLDs and X links them too. +- Off by default everywhere. Cloud opts in; self-hosted installs publish through their own X app and pay their own bill, so they only turn it on if they want to. +- Character limits are measured against the **sanitized** content — the string the publisher actually sends — in both `App\Rules\ContentFitsPlatformLimits` (save/schedule) and `HasSocialHttpClient::validateContentLength()` (publish). The editor stores HTML and per-platform rules change the length again, so measuring the raw draft blocks saving posts that publish fine and lets through posts the network rejects. Keep the two in step. +- Tests enable it explicitly with `config()->set('trypost.platforms.x.defuse_links', true)` rather than pinning an env, so the suite runs against the shipped default. +- The editor counts characters and renders the X preview client-side, so the rewrite is mirrored in `resources/js/lib/defuseXLinks.ts`. The TLD list is NOT duplicated there: `PostController@edit` sends `App\Support\LinkTlds::all()` as the `xLinkTlds` page prop, and only when defusing is on — an empty set means the feature is off, since without the list a bare host cannot be told from `Node.js`. Do not move it to the Inertia shared props; only the editor needs it. Two tests keep the mirror honest: `XLinkDefusingParityTest` runs a shared corpus through both engines over the same list and diffs the output, and `tests/Browser/XLinkDefusingTest.php` drives the real editor. +- Neither expression may use lookbehind. Safari only understands it from 16.4, esbuild cannot transpile it, and a `SyntaxError` there takes down the whole chunk — the character before a candidate URL is consumed and put back instead. + ## Git - NEVER add `Co-Authored-By` lines to commit messages. diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index c0ff2b59..9b0702eb 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -25,6 +25,7 @@ use App\Models\PostPlatform; use App\Services\Post\PostMetricsFetcher; use App\Services\Social\TikTokCreatorInfo; +use App\Support\LinkTlds; use App\Support\PostStatusRules; use Carbon\Carbon; use Illuminate\Http\JsonResponse; @@ -287,6 +288,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse 'labels' => $labels, 'signatures' => $signatures, 'authUserId' => $request->user()->id, + 'xLinkTlds' => config('trypost.platforms.x.defuse_links') ? LinkTlds::all() : [], ]); } diff --git a/app/Rules/ContentFitsPlatformLimits.php b/app/Rules/ContentFitsPlatformLimits.php index 8eb79b2c..e2c0098c 100644 --- a/app/Rules/ContentFitsPlatformLimits.php +++ b/app/Rules/ContentFitsPlatformLimits.php @@ -5,6 +5,7 @@ namespace App\Rules; use App\Enums\SocialAccount\Platform; +use App\Services\Social\ContentSanitizer; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Support\Collection; @@ -15,6 +16,11 @@ * exceeded by the submitted text. Pre-resolve the platforms the post is bound * to (App: from `post_platforms.id`; API store: from `social_accounts.id`) and * pass them in — keeps the rule decoupled from the FormRequest payload shape. + * + * Each platform is measured against its own sanitized content, matching what the + * publisher will actually send: the editor stores HTML, and per-platform rules + * (X link defusing, Telegram entity escaping) change the length again. Measuring + * the raw draft would block saving a post that publishes fine, and vice versa. */ class ContentFitsPlatformLimits implements ValidationRule { @@ -40,7 +46,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void continue; } - $over = $platform->contentOverflow($content); + $over = $platform->contentOverflow(app(ContentSanitizer::class)->displayText($content, $platform)); if ($over === 0) { continue; } diff --git a/app/Services/Post/PostPreviewer.php b/app/Services/Post/PostPreviewer.php index 549491db..ca8343ea 100644 --- a/app/Services/Post/PostPreviewer.php +++ b/app/Services/Post/PostPreviewer.php @@ -57,7 +57,7 @@ private function platformPreviews(Post $post, string $original): Collection ->values() ->map(function (PostPlatform $pp) use ($original) { $platform = $pp->socialAccount?->platform ?? $pp->platform; - $sanitized = $original === '' ? '' : $this->sanitizer->sanitize($original, $platform); + $sanitized = $this->sanitizer->sanitize($original, $platform); return [ 'post_platform_id' => $pp->id, diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index a465b97a..3977eee1 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -5,15 +5,25 @@ namespace App\Services\Social\Concerns; use App\Models\PostPlatform; +use App\Services\Social\ContentSanitizer; use App\Services\Social\TokenRedactor; +use Exception; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; trait HasSocialHttpClient { + /** + * Measures the sanitized content — the exact string the publisher sends — + * rather than the stored draft. The two differ by more than markup: X + * defusing rewrites URLs, Telegram escapes entities, and every platform + * strips HTML the editor stored. Checking the raw draft both rejected + * posts that would have fit and let through posts the network rejects. + */ protected function validateContentLength(PostPlatform $postPlatform): void { - $content = $postPlatform->post->content ?? ''; + $raw = $postPlatform->post->content ?? ''; + $content = app(ContentSanitizer::class)->displayText($raw, $postPlatform->platform); if ($postPlatform->platform->contentOverflow($content) === 0) { return; @@ -22,7 +32,7 @@ protected function validateContentLength(PostPlatform $postPlatform): void $maxLength = $postPlatform->platform->maxContentLength(); $contentLength = mb_strlen($content); - throw new \Exception( + throw new Exception( "Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)." ); } diff --git a/app/Services/Social/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php index d9fa3df0..efa6c2c0 100644 --- a/app/Services/Social/ContentSanitizer.php +++ b/app/Services/Social/ContentSanitizer.php @@ -5,19 +5,93 @@ namespace App\Services\Social; use App\Enums\SocialAccount\Platform; +use App\Support\LinkTlds; class ContentSanitizer { + /** + * Splits a candidate URL into the character that precedes it, prefix (scheme, + * optional userinfo and `www.`), host, the host's last label and the path. The + * boundary is consumed rather than looked behind so the same expression runs on + * browsers without lookbehind, and it is put back untouched by the callback. An explicit scheme is proof on its + * own that the token is a URL; a bare host only counts when its last label is a + * delegated TLD, the single thing telling `acme.com` apart from `Node.js`. + * + * Hosts are matched as Unicode letters and digits so internationalised domains + * (`café.com`, `пример.рф`) are recognised, and the lookbehind keeps a bare host + * that follows an `@` out — that is an email address, which X does not link. + */ + private const LINK_PATTERN = '~(^|[^\p{L}\p{N}\p{M}_@/.])((?:https?://(?:[^\s/@]+@)?)?(?:www\.)?)((?:[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}-]*[\p{L}\p{N}\p{M}])?\.)+([\p{L}\p{N}\p{M}-]{2,63}))(?![\p{L}\p{N}\p{M}-])((?:/\S*)?)~iu'; + public function sanitize(string $content, Platform $platform): string { return match ($platform) { Platform::LinkedIn, Platform::LinkedInPage => $this->convertBoldAndStrip($content), Platform::Mastodon => $this->stripUnsafeHtml($content), Platform::Telegram => $this->toTelegramHtml($content), + Platform::X => $this->defuseLinks($this->stripHtml($content)), default => $this->stripHtml($content), }; } + /** + * The content as a reader will see it, which is what a character limit applies + * to. Mirrors {@see self::sanitize()} arm for arm, because only two of them + * leave markup behind: + * + * - Telegram is handed HTML with entities escaped for `parse_mode=HTML`, so its + * tags and entities both resolve away — `&` renders as one character. + * - Mastodon keeps an HTML subset but its sanitizer already decoded entities, so + * only the tags come off. Decoding again would eat a literal `&` the user + * typed and undercount the post. + * - Everything else, X included, is already plain text: the defused form is + * literally what gets posted, so it counts as-is. + */ + public function displayText(string $content, Platform $platform): string + { + $sanitized = $this->sanitize($content, $platform); + + return match ($platform) { + Platform::Telegram => html_entity_decode(strip_tags($sanitized), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + Platform::Mastodon => strip_tags($sanitized), + default => $sanitized, + }; + } + + /** + * Rewrites every URL into a non-clickable form (`example.com` → + * `example(.)com`), dropping the scheme and any `www.` prefix and breaking + * every dot of the host — leaving one intact dot would still leave a + * resolvable domain for X to detect. + * + * X bills a post carrying a link at a much higher rate than a plain post, and + * its algorithm demotes link posts, so neither side of that wants the raw URL. + * Off by default; opt in with `X_DEFUSE_LINKS`. + */ + private function defuseLinks(string $content): string + { + if (! config('trypost.platforms.x.defuse_links')) { + return $content; + } + + $defused = preg_replace_callback( + self::LINK_PATTERN, + function (array $matches): string { + [$whole, $boundary, $prefix, $host, $tld] = $matches; + $path = $matches[5] ?? ''; + + if ($prefix === '' && ! LinkTlds::has($tld)) { + return $whole; + } + + return $boundary.str_replace('.', '(.)', $host).$path; + }, + $content, + ); + + return $defused ?? $content; + } + /** * Telegram's `parse_mode=HTML` accepts a small tag allowlist and rejects * the rest; bare ampersands must be escaped or the parser errors. diff --git a/app/Support/LinkTlds.php b/app/Support/LinkTlds.php new file mode 100644 index 00000000..1c0239b0 --- /dev/null +++ b/app/Support/LinkTlds.php @@ -0,0 +1,211 @@ + + */ +class LinkTlds +{ + /** @var array */ + private const TLDS = [ + 'aaa', 'aarp', 'abb', 'abbott', 'abbvie', 'abc', 'able', 'abogado', 'abudhabi', 'ac', 'academy', + 'accenture', 'accountant', 'accountants', 'aco', 'actor', 'ad', 'ads', 'adult', 'ae', 'aeg', 'aero', + 'aetna', 'af', 'afl', 'africa', 'ag', 'agakhan', 'agency', 'ai', 'aig', 'airbus', 'airforce', + 'airtel', 'akdn', 'al', 'alibaba', 'alipay', 'allfinanz', 'allstate', 'ally', 'alsace', 'alstom', + 'am', 'amazon', 'americanexpress', 'americanfamily', 'amex', 'amfam', 'amica', 'amsterdam', + 'analytics', 'android', 'anquan', 'anz', 'ao', 'aol', 'apartments', 'app', 'apple', 'aq', + 'aquarelle', 'ar', 'arab', 'aramco', 'archi', 'army', 'arpa', 'art', 'arte', 'as', 'asda', 'asia', + 'associates', 'at', 'athleta', 'attorney', 'au', 'auction', 'audi', 'audible', 'audio', 'auspost', + 'author', 'auto', 'autos', 'aw', 'aws', 'ax', 'axa', 'az', 'azure', 'ba', 'baby', 'baidu', + 'banamex', 'band', 'bank', 'bar', 'barcelona', 'barclaycard', 'barclays', 'barefoot', 'bargains', + 'baseball', 'basketball', 'bauhaus', 'bayern', 'bb', 'bbc', 'bbt', 'bbva', 'bcg', 'bcn', 'bd', 'be', + 'beats', 'beauty', 'beer', 'berlin', 'best', 'bestbuy', 'bet', 'bf', 'bg', 'bh', 'bharti', 'bi', + 'bible', 'bid', 'bike', 'bing', 'bingo', 'bio', 'biz', 'bj', 'black', 'blackfriday', 'blockbuster', + 'blog', 'bloomberg', 'blue', 'bm', 'bms', 'bmw', 'bn', 'bnpparibas', 'bo', 'boats', 'boehringer', + 'bofa', 'bom', 'bond', 'boo', 'book', 'booking', 'bosch', 'bostik', 'boston', 'bot', 'boutique', + 'box', 'br', 'bradesco', 'bridgestone', 'broadway', 'broker', 'brother', 'brussels', 'bs', 'bt', + 'build', 'builders', 'business', 'buy', 'buzz', 'bv', 'bw', 'by', 'bz', 'bzh', 'ca', 'cab', 'cafe', + 'cal', 'call', 'calvinklein', 'cam', 'camera', 'camp', 'canon', 'capetown', 'capital', 'capitalone', + 'car', 'caravan', 'cards', 'care', 'career', 'careers', 'cars', 'casa', 'case', 'cash', 'casino', + 'cat', 'catering', 'catholic', 'cba', 'cbn', 'cbre', 'cc', 'cd', 'center', 'ceo', 'cern', 'cf', + 'cfa', 'cfd', 'cg', 'ch', 'chanel', 'channel', 'charity', 'chase', 'chat', 'cheap', 'chintai', + 'christmas', 'chrome', 'church', 'ci', 'cipriani', 'circle', 'cisco', 'citadel', 'citi', 'citic', + 'city', 'ck', 'cl', 'claims', 'cleaning', 'click', 'clinic', 'clinique', 'clothing', 'cloud', + 'club', 'clubmed', 'cm', 'cn', 'co', 'coach', 'codes', 'coffee', 'college', 'cologne', 'com', + 'commbank', 'community', 'company', 'compare', 'computer', 'comsec', 'condos', 'construction', + 'consulting', 'contact', 'contractors', 'cooking', 'cool', 'coop', 'corsica', 'country', 'coupon', + 'coupons', 'courses', 'cpa', 'cr', 'credit', 'creditcard', 'creditunion', 'cricket', 'crown', 'crs', + 'cruise', 'cruises', 'cu', 'cuisinella', 'cv', 'cw', 'cx', 'cy', 'cymru', 'cyou', 'cz', 'dad', + 'dance', 'data', 'date', 'dating', 'datsun', 'day', 'dclk', 'dds', 'de', 'deal', 'dealer', 'deals', + 'degree', 'delivery', 'dell', 'deloitte', 'delta', 'democrat', 'dental', 'dentist', 'desi', + 'design', 'dev', 'dhl', 'diamonds', 'diet', 'digital', 'direct', 'directory', 'discount', + 'discover', 'dish', 'diy', 'dj', 'dk', 'dm', 'dnp', 'do', 'docs', 'doctor', 'dog', 'domains', 'dot', + 'download', 'drive', 'dtv', 'dubai', 'dupont', 'durban', 'dvag', 'dvr', 'dz', 'earth', 'eat', 'ec', + 'eco', 'edeka', 'edu', 'education', 'ee', 'eg', 'email', 'emerck', 'energy', 'engineer', + 'engineering', 'enterprises', 'epson', 'equipment', 'er', 'ericsson', 'erni', 'es', 'esq', 'estate', + 'et', 'eu', 'eurovision', 'eus', 'events', 'exchange', 'expert', 'exposed', 'express', 'extraspace', + 'fage', 'fail', 'fairwinds', 'faith', 'family', 'fan', 'fans', 'farm', 'farmers', 'fashion', 'fast', + 'fedex', 'feedback', 'ferrari', 'ferrero', 'fi', 'fidelity', 'fido', 'film', 'final', 'finance', + 'financial', 'fire', 'firestone', 'firmdale', 'fish', 'fishing', 'fit', 'fitness', 'fj', 'fk', + 'flickr', 'flights', 'flir', 'florist', 'flowers', 'fly', 'fm', 'fo', 'foo', 'food', 'football', + 'ford', 'forex', 'forsale', 'forum', 'foundation', 'fox', 'fr', 'free', 'fresenius', 'frl', + 'frogans', 'frontier', 'ftr', 'fujitsu', 'fun', 'fund', 'furniture', 'futbol', 'fyi', 'ga', 'gal', + 'gallery', 'gallo', 'gallup', 'game', 'games', 'gap', 'garden', 'gay', 'gb', 'gbiz', 'gd', 'gdn', + 'ge', 'gea', 'gent', 'genting', 'george', 'gf', 'gg', 'ggee', 'gh', 'gi', 'gift', 'gifts', 'gives', + 'giving', 'gl', 'glass', 'gle', 'global', 'globo', 'gm', 'gmail', 'gmbh', 'gmo', 'gmx', 'gn', + 'godaddy', 'gold', 'goldpoint', 'golf', 'goodyear', 'goog', 'google', 'gop', 'got', 'gov', 'gp', + 'gq', 'gr', 'grainger', 'graphics', 'gratis', 'green', 'gripe', 'grocery', 'group', 'gs', 'gt', + 'gu', 'gucci', 'guge', 'guide', 'guitars', 'guru', 'gw', 'gy', 'hair', 'hamburg', 'hangout', 'haus', + 'hbo', 'hdfc', 'hdfcbank', 'health', 'healthcare', 'help', 'helsinki', 'here', 'hermes', 'hiphop', + 'hisamitsu', 'hitachi', 'hiv', 'hk', 'hkt', 'hm', 'hn', 'hockey', 'holdings', 'holiday', + 'homedepot', 'homegoods', 'homes', 'homesense', 'honda', 'horse', 'hospital', 'host', 'hosting', + 'hot', 'hotels', 'hotmail', 'house', 'how', 'hr', 'hsbc', 'ht', 'hu', 'hughes', 'hyatt', 'hyundai', + 'ibm', 'icbc', 'ice', 'icu', 'id', 'ie', 'ieee', 'ifm', 'ikano', 'il', 'im', 'imamat', 'imdb', + 'immo', 'immobilien', 'in', 'inc', 'industries', 'infiniti', 'info', 'ing', 'ink', 'institute', + 'insurance', 'insure', 'int', 'international', 'intuit', 'investments', 'io', 'ipiranga', 'iq', + 'ir', 'irish', 'is', 'ismaili', 'ist', 'istanbul', 'it', 'itau', 'itv', 'jaguar', 'java', 'jcb', + 'je', 'jeep', 'jetzt', 'jewelry', 'jio', 'jll', 'jm', 'jmp', 'jnj', 'jo', 'jobs', 'joburg', 'jot', + 'joy', 'jp', 'jpmorgan', 'jprs', 'juegos', 'juniper', 'kaufen', 'kddi', 'ke', 'kerryhotels', + 'kerryproperties', 'kfh', 'kg', 'kh', 'ki', 'kia', 'kids', 'kim', 'kindle', 'kitchen', 'kiwi', 'km', + 'kn', 'koeln', 'komatsu', 'kosher', 'kp', 'kpmg', 'kpn', 'kr', 'krd', 'kred', 'kuokgroup', 'kw', + 'ky', 'kyoto', 'kz', 'la', 'lacaixa', 'lamborghini', 'lamer', 'land', 'landrover', 'lanxess', + 'lasalle', 'lat', 'latino', 'latrobe', 'law', 'lawyer', 'lb', 'lc', 'lds', 'lease', 'leclerc', + 'lefrak', 'legal', 'lego', 'lexus', 'lgbt', 'li', 'lidl', 'life', 'lifeinsurance', 'lifestyle', + 'lighting', 'like', 'lilly', 'limited', 'limo', 'lincoln', 'link', 'live', 'living', 'lk', 'llc', + 'llp', 'loan', 'loans', 'locker', 'locus', 'lol', 'london', 'lotte', 'lotto', 'love', 'lpl', + 'lplfinancial', 'lr', 'ls', 'lt', 'ltd', 'ltda', 'lu', 'lundbeck', 'luxe', 'luxury', 'lv', 'ly', + 'ma', 'madrid', 'maif', 'maison', 'makeup', 'man', 'management', 'mango', 'map', 'market', + 'marketing', 'markets', 'marriott', 'marshalls', 'mattel', 'mba', 'mc', 'mckinsey', 'md', 'me', + 'med', 'media', 'meet', 'melbourne', 'meme', 'memorial', 'men', 'menu', 'merck', 'merckmsd', 'mg', + 'mh', 'miami', 'microsoft', 'mil', 'mini', 'mint', 'mit', 'mitsubishi', 'mk', 'ml', 'mlb', 'mls', + 'mm', 'mma', 'mn', 'mo', 'mobi', 'mobile', 'moda', 'moe', 'moi', 'mom', 'monash', 'money', + 'monster', 'mormon', 'mortgage', 'moscow', 'moto', 'motorcycles', 'mov', 'movie', 'mp', 'mq', 'mr', + 'ms', 'msd', 'mt', 'mtn', 'mtr', 'mu', 'museum', 'music', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'nab', + 'nagoya', 'name', 'navy', 'nba', 'nc', 'ne', 'nec', 'net', 'netbank', 'netflix', 'network', + 'neustar', 'new', 'news', 'next', 'nextdirect', 'nexus', 'nf', 'nfl', 'ng', 'ngo', 'nhk', 'ni', + 'nico', 'nike', 'nikon', 'ninja', 'nissan', 'nissay', 'nl', 'no', 'nokia', 'norton', 'now', + 'nowruz', 'nowtv', 'np', 'nr', 'nra', 'nrw', 'ntt', 'nu', 'nyc', 'nz', 'obi', 'observer', 'office', + 'okinawa', 'olayan', 'olayangroup', 'ollo', 'om', 'omega', 'one', 'ong', 'onl', 'online', 'ooo', + 'open', 'oracle', 'orange', 'org', 'organic', 'origins', 'osaka', 'otsuka', 'ott', 'ovh', 'pa', + 'page', 'panasonic', 'paris', 'pars', 'partners', 'parts', 'party', 'pay', 'pccw', 'pe', 'pet', + 'pf', 'pfizer', 'pg', 'ph', 'pharmacy', 'phd', 'philips', 'phone', 'photo', 'photography', 'photos', + 'physio', 'pics', 'pictet', 'pictures', 'pid', 'pin', 'ping', 'pink', 'pioneer', 'pizza', 'pk', + 'pl', 'place', 'play', 'playstation', 'plumbing', 'plus', 'pm', 'pn', 'pnc', 'pohl', 'poker', + 'politie', 'porn', 'post', 'pr', 'praxi', 'press', 'prime', 'pro', 'prod', 'productions', 'prof', + 'progressive', 'promo', 'properties', 'property', 'protection', 'pru', 'prudential', 'ps', 'pt', + 'pub', 'pw', 'pwc', 'py', 'qa', 'qpon', 'quebec', 'quest', 'racing', 'radio', 're', 'read', + 'realestate', 'realtor', 'realty', 'recipes', 'red', 'redumbrella', 'rehab', 'reise', 'reisen', + 'reit', 'reliance', 'ren', 'rent', 'rentals', 'repair', 'report', 'republican', 'rest', + 'restaurant', 'review', 'reviews', 'rexroth', 'rich', 'richardli', 'ricoh', 'ril', 'rio', 'rip', + 'ro', 'rocks', 'rodeo', 'rogers', 'room', 'rs', 'rsvp', 'ru', 'rugby', 'ruhr', 'run', 'rw', 'rwe', + 'ryukyu', 'sa', 'saarland', 'safe', 'safety', 'sakura', 'sale', 'salon', 'samsclub', 'samsung', + 'sandvik', 'sandvikcoromant', 'sanofi', 'sap', 'sarl', 'sas', 'save', 'saxo', 'sb', 'sbi', 'sbs', + 'sc', 'scb', 'schaeffler', 'schmidt', 'scholarships', 'school', 'schule', 'schwarz', 'science', + 'scot', 'sd', 'se', 'search', 'seat', 'secure', 'security', 'seek', 'select', 'sener', 'services', + 'seven', 'sew', 'sex', 'sexy', 'sfr', 'sg', 'sh', 'shangrila', 'sharp', 'shell', 'shia', 'shiksha', + 'shoes', 'shop', 'shopping', 'shouji', 'show', 'si', 'silk', 'sina', 'singles', 'site', 'sj', 'sk', + 'ski', 'skin', 'sky', 'skype', 'sl', 'sling', 'sm', 'smart', 'smile', 'sn', 'sncf', 'so', 'soccer', + 'social', 'softbank', 'software', 'sohu', 'solar', 'solutions', 'song', 'sony', 'soy', 'spa', + 'space', 'sport', 'spot', 'sr', 'srl', 'ss', 'st', 'stada', 'staples', 'star', 'statebank', + 'statefarm', 'stc', 'stcgroup', 'stockholm', 'storage', 'store', 'stream', 'studio', 'study', + 'style', 'su', 'sucks', 'supplies', 'supply', 'support', 'surf', 'surgery', 'suzuki', 'sv', + 'swatch', 'swiss', 'sx', 'sy', 'sydney', 'systems', 'sz', 'tab', 'taipei', 'talk', 'taobao', + 'target', 'tatamotors', 'tatar', 'tattoo', 'tax', 'taxi', 'tc', 'tci', 'td', 'tdk', 'team', 'tech', + 'technology', 'tel', 'temasek', 'tennis', 'teva', 'tf', 'tg', 'th', 'thd', 'theater', 'theatre', + 'tiaa', 'tickets', 'tienda', 'tips', 'tires', 'tirol', 'tj', 'tjmaxx', 'tjx', 'tk', 'tkmaxx', 'tl', + 'tm', 'tmall', 'tn', 'to', 'today', 'tokyo', 'tools', 'top', 'toray', 'toshiba', 'total', 'tours', + 'town', 'toyota', 'toys', 'tr', 'trade', 'trading', 'training', 'travel', 'travelers', + 'travelersinsurance', 'trust', 'trv', 'tt', 'tube', 'tui', 'tunes', 'tushu', 'tv', 'tvs', 'tw', + 'tz', 'ua', 'ubank', 'ubs', 'ug', 'uk', 'unicom', 'university', 'uno', 'uol', 'ups', 'us', 'uy', + 'uz', 'va', 'vacations', 'vana', 'vanguard', 'vc', 've', 'vegas', 'ventures', 'verisign', + 'vermögensberater', 'vermögensberatung', 'versicherung', 'vet', 'vg', 'vi', 'viajes', 'video', + 'vig', 'viking', 'villas', 'vin', 'vip', 'virgin', 'visa', 'vision', 'viva', 'vivo', 'vlaanderen', + 'vn', 'vodka', 'volvo', 'vote', 'voting', 'voto', 'voyage', 'vu', 'wales', 'walmart', 'walter', + 'wang', 'wanggou', 'watch', 'watches', 'weather', 'weatherchannel', 'web', 'webcam', 'weber', + 'website', 'wed', 'wedding', 'weibo', 'weir', 'wf', 'whoswho', 'wien', 'wiki', 'williamhill', 'win', + 'windows', 'wine', 'winners', 'wme', 'woodside', 'work', 'works', 'world', 'wow', 'ws', 'wtc', + 'wtf', 'xbox', 'xerox', 'xihuan', 'xin', 'xn--11b4c3d', 'xn--1ck2e1b', 'xn--1qqw23a', 'xn--2scrj9c', + 'xn--30rr7y', 'xn--3bst00m', 'xn--3ds443g', 'xn--3e0b707e', 'xn--3hcrj9c', 'xn--3pxu8k', + 'xn--42c2d9a', 'xn--45br5cyl', 'xn--45brj9c', 'xn--45q11c', 'xn--4dbrk0ce', 'xn--4gbrim', + 'xn--54b7fta0cc', 'xn--55qw42g', 'xn--55qx5d', 'xn--5su34j936bgsg', 'xn--5tzm5g', 'xn--6frz82g', + 'xn--6qq986b3xl', 'xn--80adxhks', 'xn--80ao21a', 'xn--80aqecdr1a', 'xn--80asehdb', 'xn--80aswg', + 'xn--8y0a063a', 'xn--90a3ac', 'xn--90ae', 'xn--90ais', 'xn--9dbq2a', 'xn--9et52u', 'xn--9krt00a', + 'xn--b4w605ferd', 'xn--bck1b9a5dre4c', 'xn--c1avg', 'xn--c2br7g', 'xn--cck2b3b', 'xn--cckwcxetd', + 'xn--cg4bki', 'xn--clchc0ea0b2g2a9gcd', 'xn--czr694b', 'xn--czrs0t', 'xn--czru2d', 'xn--d1acj3b', + 'xn--d1alf', 'xn--e1a4c', 'xn--eckvdtc9d', 'xn--efvy88h', 'xn--fct429k', 'xn--fhbei', + 'xn--fiq228c5hs', 'xn--fiq64b', 'xn--fiqs8s', 'xn--fiqz9s', 'xn--fjq720a', 'xn--flw351e', + 'xn--fpcrj9c3d', 'xn--fzc2c9e2c', 'xn--fzys8d69uvgm', 'xn--g2xx48c', 'xn--gckr3f0f', 'xn--gecrj9c', + 'xn--gk3at1e', 'xn--h2breg3eve', 'xn--h2brj9c', 'xn--h2brj9c8c', 'xn--hxt814e', 'xn--i1b6b1a6a2e', + 'xn--imr513n', 'xn--io0a7i', 'xn--j1aef', 'xn--j1amh', 'xn--j6w193g', 'xn--jlq480n2rg', + 'xn--jvr189m', 'xn--kcrx77d1x4a', 'xn--kprw13d', 'xn--kpry57d', 'xn--kput3i', 'xn--l1acc', + 'xn--lgbbat1ad8j', 'xn--mgb9awbf', 'xn--mgba3a3ejt', 'xn--mgba3a4f16a', 'xn--mgba7c0bbn0a', + 'xn--mgbaam7a8h', 'xn--mgbab2bd', 'xn--mgbah1a3hjkrd', 'xn--mgbai9azgqp6j', 'xn--mgbayh7gpa', + 'xn--mgbbh1a', 'xn--mgbbh1a71e', 'xn--mgbc0a9azcg', 'xn--mgbca7dzdo', 'xn--mgbcpq6gpa1a', + 'xn--mgberp4a5d4ar', 'xn--mgbgu82a', 'xn--mgbi4ecexp', 'xn--mgbpl2fh', 'xn--mgbt3dhd', + 'xn--mgbtx2b', 'xn--mgbx4cd0ab', 'xn--mix891f', 'xn--mk1bu44c', 'xn--mxtq1m', 'xn--ngbc5azd', + 'xn--ngbe9e0a', 'xn--ngbrx', 'xn--node', 'xn--nqv7f', 'xn--nqv7fs00ema', 'xn--nyqy26a', + 'xn--o3cw4h', 'xn--ogbpf8fl', 'xn--otu796d', 'xn--p1acf', 'xn--p1ai', 'xn--pgbs0dh', 'xn--pssy2u', + 'xn--q7ce6a', 'xn--q9jyb4c', 'xn--qcka1pmc', 'xn--qxa6a', 'xn--qxam', 'xn--rhqv96g', 'xn--rovu88b', + 'xn--rvc1e0am3e', 'xn--s9brj9c', 'xn--ses554g', 'xn--t60b56a', 'xn--tckwe', 'xn--tiq49xqyj', + 'xn--unup4y', 'xn--vermgensberater-ctb', 'xn--vermgensberatung-pwb', 'xn--vhquv', 'xn--vuq861b', + 'xn--w4r85el8fhu5dnra', 'xn--w4rs40l', 'xn--wgbh1c', 'xn--wgbl6a', 'xn--xhq521b', + 'xn--xkc2al3hye2a', 'xn--xkc2dl3a5ee0h', 'xn--y9a3aq', 'xn--yfro4i67o', 'xn--ygbi2ammx', + 'xn--zfr164b', 'xxx', 'xyz', 'yachts', 'yahoo', 'yamaxun', 'yandex', 'ye', 'yodobashi', 'yoga', + 'yokohama', 'you', 'youtube', 'yt', 'yun', 'za', 'zappos', 'zara', 'zero', 'zip', 'zm', 'zone', + 'zuerich', 'zw', 'ελ', 'ευ', 'бг', 'бел', 'дети', 'ею', 'католик', 'ком', 'мкд', 'мон', 'москва', + 'онлайн', 'орг', 'рус', 'рф', 'сайт', 'срб', 'укр', 'қаз', 'հայ', 'ישראל', 'קום', 'ابوظبي', + 'ارامكو', 'الاردن', 'البحرين', 'الجزائر', 'السعودية', 'العليان', 'المغرب', 'امارات', 'ایران', + 'بارت', 'بازار', 'بيتك', 'بھارت', 'تونس', 'سودان', 'سورية', 'شبكة', 'عراق', 'عرب', 'عمان', 'فلسطين', + 'قطر', 'كاثوليك', 'كوم', 'مصر', 'مليسيا', 'موريتانيا', 'موقع', 'همراه', 'پاکستان', 'ڀارت', 'कॉम', + 'नेट', 'भारत', 'भारतम्', 'भारोत', 'संगठन', 'বাংলা', 'ভারত', 'ভাৰত', 'ਭਾਰਤ', 'ભારત', 'ଭାରତ', + 'இந்தியா', 'இலங்கை', 'சிங்கப்பூர்', 'భారత్', 'ಭಾರತ', 'ഭാരതം', 'ලංකා', 'คอม', 'ไทย', 'ລາວ', 'გე', + 'みんな', 'アマゾン', 'クラウド', 'グーグル', 'コム', 'ストア', 'セール', 'ファッション', 'ポイント', '世界', '中信', '中国', '中國', '中文网', + '亚马逊', '企业', '佛山', '信息', '健康', '八卦', '公司', '公益', '台湾', '台灣', '商城', '商店', '商标', '嘉里', '嘉里大酒店', '在线', + '大拿', '天主教', '娱乐', '家電', '广东', '微博', '慈善', '我爱你', '手机', '招聘', '政务', '政府', '新加坡', '新闻', '时尚', '書籍', + '机构', '淡马锡', '游戏', '澳門', '点看', '移动', '组织机构', '网址', '网店', '网站', '网络', '联通', '谷歌', '购物', '通販', '集团', + '電訊盈科', '飞利浦', '食品', '餐厅', '香格里拉', '香港', '닷넷', '닷컴', '삼성', '한국', + ]; + + /** + * The whole set, for handing to a client that has to recognise links itself — + * the post editor counts characters and previews without a round trip. Shipping + * it from here keeps this class the only place the list is declared. + * + * @return array + */ + public static function all(): array + { + return self::TLDS; + } + + /** + * Whether the given label is a delegated top-level domain. Backed by a flipped + * lookup built once per process, so this stays O(1) across a whole post. + */ + public static function has(string $tld): bool + { + static $lookup = null; + + $lookup ??= array_flip(self::TLDS); + + return isset($lookup[mb_strtolower($tld)]); + } +} diff --git a/config/trypost.php b/config/trypost.php index 8b6a1dc7..f9d038e4 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -170,6 +170,7 @@ 'x' => [ 'enabled' => env('X_ENABLED', true), 'api' => env('X_API', 'https://api.x.com/2'), + 'defuse_links' => (bool) env('X_DEFUSE_LINKS', false), ], 'tiktok' => [ 'enabled' => env('TIKTOK_ENABLED', true), diff --git a/resources/js/components/posts/editor/PostEditorComposer.vue b/resources/js/components/posts/editor/PostEditorComposer.vue index 775c615f..f30f5b3a 100644 --- a/resources/js/components/posts/editor/PostEditorComposer.vue +++ b/resources/js/components/posts/editor/PostEditorComposer.vue @@ -24,6 +24,7 @@ import SignaturesModal from '@/components/posts/SignaturesModal.vue'; import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; +import { useXLinkDefuser } from '@/composables/useXLinkDefuser'; import date from '@/date'; import { classify, isDocument, isImage, isVideo, MediaType } from '@/lib/mediaType'; import type { MediaItem } from '@/types/media'; @@ -89,9 +90,12 @@ const openPreview = (item: MediaItem) => { ); }; +/** What each network will actually receive — X publishes its links defused. */ +const { contentFor } = useXLinkDefuser(); + const limitsWithUsage = computed(() => props.platformLimits.map((p) => { - const used = content.value.length; + const used = contentFor(content.value, p.platform).length; const ratio = p.maxLength > 0 ? used / p.maxLength : 0; const state = ratio > 1 ? 'over' : ratio >= 0.9 ? 'warn' : 'ok'; return { ...p, used, state }; @@ -104,9 +108,19 @@ const limitClass = (state: string): string => { return 'border-foreground bg-card text-foreground'; }; +/** + * The limit expressed against the draft the user is typing. When a network rewrites + * the text before publishing, the allowance in draft characters shifts by whatever + * the rewrite adds or removes, so the highlight lands on the text that truly spills. + */ const smallestLimit = computed(() => { if (props.platformLimits.length === 0) return null; - return Math.min(...props.platformLimits.map((p) => p.maxLength)); + + const draftLength = content.value.length; + + return Math.min( + ...props.platformLimits.map((p) => p.maxLength + (draftLength - contentFor(content.value, p.platform).length)), + ); }); const overflowParts = computed(() => { @@ -450,6 +464,7 @@ const onAltTextSave = (alt: string): void => { diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index baf6a15b..69e38bd8 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { getPlatformLabel } from '@/composables/usePlatformLogo'; +import { useXLinkDefuser } from '@/composables/useXLinkDefuser'; import type { MediaItem } from '@/types/media'; import BlueskyPreview from './BlueskyPreview.vue'; @@ -40,6 +41,15 @@ interface Props { const props = defineProps(); +const { contentFor } = useXLinkDefuser(); + +/** + * X publishes links defused (`acme(.)com`), so the preview has to show that or it + * promises text the network never receives. Every preview goes through here, which + * keeps the rewrite in one place on the client just as it is on the server. + */ +const previewContent = computed((): string => contentFor(props.content, props.platform)); + const resolvedSocialAccount = computed((): SocialAccount => props.socialAccount ?? { id: '', platform: props.platform, @@ -88,7 +98,7 @@ const previewComponent = computed(() => { -
+
{{ content }}
diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index 0ee49eb6..7172ab91 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -1,9 +1,11 @@ import { trans } from 'laravel-vue-i18n'; import { computed, type ComputedRef, type Ref } from 'vue'; + import { getMediaItemIssue, getMediaValidationWarning } from '@/composables/useMedia'; import { getMediaRulesForContentType } from '@/composables/useMediaRules'; import { getPlatformLabel } from '@/composables/usePlatformLogo'; +import { useXLinkDefuser } from '@/composables/useXLinkDefuser'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; import { Platform } from '@/types/platform'; @@ -153,6 +155,8 @@ interface UsePostComplianceOptions { } export const usePostCompliance = (opts: UsePostComplianceOptions) => { + const { contentFor } = useXLinkDefuser(); + const { post, content, media, selectedPlatformIds, platformContentTypes, platformMeta, platformConfigs } = opts; const selectedPlatforms = computed(() => post.value.post_platforms.filter( @@ -228,10 +232,10 @@ export const usePostCompliance = (opts: UsePostComplianceOptions) => { ); const contentLengthOverflows = computed(() => { - const len = content.value.length; return platformLimits.value - .filter((p) => len > p.maxLength) - .map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength })); + .map((p) => ({ p, len: contentFor(content.value, p.platform).length })) + .filter(({ p, len }) => len > p.maxLength) + .map(({ p, len }) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength })); }); const canSchedule = computed(() => { diff --git a/resources/js/composables/useXLinkDefuser.ts b/resources/js/composables/useXLinkDefuser.ts new file mode 100644 index 00000000..b993a785 --- /dev/null +++ b/resources/js/composables/useXLinkDefuser.ts @@ -0,0 +1,27 @@ +import { usePage } from '@inertiajs/vue3'; +import { computed } from 'vue'; + +import { contentForPlatform } from '@/lib/defuseXLinks'; + +/** + * X publishes links defused (`acme(.)com`), so the editor has to count characters + * and preview against that text rather than the draft. + * + * The TLD set arrives as a page prop from `App\Support\LinkTlds` instead of being + * duplicated in the bundle, and the controller sends it only when defusing is on. + * An empty set therefore means the feature is off and every platform gets its text + * back untouched — without the list a bare host cannot be told from `Node.js`, so + * defusing half of them would be worse than leaving them alone. + */ +export const useXLinkDefuser = () => { + const page = usePage(); + + const tlds = computed>( + () => new Set((page.props.xLinkTlds as string[] | undefined) ?? []), + ); + + const contentFor = (content: string, platform: string): string => + contentForPlatform(content, platform, tlds.value); + + return { contentFor }; +}; diff --git a/resources/js/lib/defuseXLinks.ts b/resources/js/lib/defuseXLinks.ts new file mode 100644 index 00000000..544221e2 --- /dev/null +++ b/resources/js/lib/defuseXLinks.ts @@ -0,0 +1,39 @@ +import { Platform } from '@/types/platform'; + +/** + * Mirror of the `Platform::X` branch of `App\Services\Social\ContentSanitizer`. + * The editor needs it to count characters and render the preview against the text + * that will actually be posted, and it cannot ask the server on every keystroke. + * + * Keep it in step with the PHP: a scheme (with optional userinfo) or `www.` proves + * a token is a URL on its own, while a bare host only counts when its last label is + * a delegated TLD — the single thing telling `acme.com` apart from `Node.js`. + * + * The character before a candidate is consumed and put back rather than checked with + * a lookbehind, which Safari only understands from 16.4 on. + */ +const LINK_PATTERN = + /(^|[^\p{L}\p{N}\p{M}_@/.])((?:https?:\/\/(?:[^\s/@]+@)?)?(?:www\.)?)((?:[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}-]*[\p{L}\p{N}\p{M}])?\.)+([\p{L}\p{N}\p{M}-]{2,63}))(?![\p{L}\p{N}\p{M}-])((?:\/\S*)?)/giu; + +/** + * The delegated TLDs are handed in rather than declared here: `App\Support\LinkTlds` + * is the only place the list lives, and the editor receives it as a page prop. + */ +export const defuseXLinks = (content: string, tlds: ReadonlySet): string => + content.replace( + LINK_PATTERN, + (whole: string, boundary: string, prefix: string, host: string, tld: string, path: string): string => + prefix === '' && !tlds.has(tld.toLowerCase()) + ? whole + : boundary + host.replaceAll('.', '(.)') + path, + ); + +/** + * The content as the given platform will show it. Only X rewrites anything today, + * so every other network gets its text back untouched. + */ +export const contentForPlatform = ( + content: string, + platform: string, + tlds: ReadonlySet, +): string => (platform === Platform.X && tlds.size > 0 ? defuseXLinks(content, tlds) : content); diff --git a/tests/Browser/XLinkDefusingTest.php b/tests/Browser/XLinkDefusingTest.php new file mode 100644 index 00000000..e1e04e81 --- /dev/null +++ b/tests/Browser/XLinkDefusingTest.php @@ -0,0 +1,101 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $workspace->members()->attach($user->id, ['role' => Role::Member->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $account = SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'content' => $content, + ]); + + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => Platform::X, + 'content_type' => ContentType::XPost, + 'enabled' => true, + ]); + + test()->actingAs($user); + + return $post; +} + +function waitForXDefusingTestId(mixed $page, string $testId): void +{ + $page->script(<< { + const sel = '[data-testid="{$testId}"]'; + for (let i = 0; i < 100; i++) { + const el = document.querySelector(sel); + if (el && el.getBoundingClientRect().height > 0) return; + await new Promise((r) => setTimeout(r, 50)); + } + })(); + JS); +} + +test('the x preview shows the link as it will be published', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $post = seedXDefusingPost('New post: https://acme.com/blog'); + + $page = visit(route('app.posts.edit', $post))->resize(375, 812); + waitForXDefusingTestId($page, 'editor-nav-preview'); + $page->click('@editor-nav-preview'); + waitForXDefusingTestId($page, 'x-preview-content'); + + $page->assertSee('New post: acme(.)com/blog') + ->assertDontSee('https://acme.com/blog') + ->assertNoJavaScriptErrors(); +}); + +test('the x preview leaves the link alone when defusing is disabled', function () { + config()->set('trypost.platforms.x.defuse_links', false); + + $post = seedXDefusingPost('New post: https://acme.com/blog'); + + $page = visit(route('app.posts.edit', $post))->resize(375, 812); + waitForXDefusingTestId($page, 'editor-nav-preview'); + $page->click('@editor-nav-preview'); + waitForXDefusingTestId($page, 'x-preview-content'); + + $page->assertSee('New post: https://acme.com/blog') + ->assertNoJavaScriptErrors(); +}); + +test('the character counter counts the defused length for x', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + // 31 raw characters; defused to 25, since the scheme goes and one dot grows. + $post = seedXDefusingPost('New post: https://acme.com/blog'); + + $page = visit(route('app.posts.edit', $post)); + waitForXDefusingTestId($page, 'content-counter-x'); + + $page->assertSee('25/280') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 71551498..e68ea13e 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -15,6 +15,8 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceLabel; +use App\Support\LinkTlds; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; @@ -1482,3 +1484,25 @@ $response->assertRedirect(); }); + +test('the editor receives the tld list only while x link defusing is on', function (bool $enabled, bool $expectsList) { + config()->set('trypost.platforms.x.defuse_links', $enabled); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $this->actingAs($this->user) + ->get(route('app.posts.edit', $post)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('posts/Edit') + ->where('xLinkTlds', fn (Collection $tlds): bool => $expectsList + ? $tlds->contains('com') && $tlds->count() === count(LinkTlds::all()) + : $tlds->isEmpty()) + ); +})->with([ + 'enabled' => [true, true], + 'disabled' => [false, false], +]); diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 752627bb..a69e6721 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -1874,3 +1874,21 @@ function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool ); }); }); + +test('bluesky publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + Http::fake([ + 'https://bsky.social/xrpc/com.atproto.repo.createRecord' => Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', + 'cid' => 'bafyreiabc123', + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') + && $request['record']['text'] === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/ContentSanitizerTest.php b/tests/Feature/Services/Social/ContentSanitizerTest.php index 4a0e607a..91e7c0d9 100644 --- a/tests/Feature/Services/Social/ContentSanitizerTest.php +++ b/tests/Feature/Services/Social/ContentSanitizerTest.php @@ -5,6 +5,26 @@ use App\Enums\SocialAccount\Platform; use App\Services\Social\ContentSanitizer; +/** + * Every platform except X. Derived from the enum so a network added later is covered + * without anyone remembering to list it here — defusing must stay X-only. + * + * @return array> + */ +function platformsThatKeepLinks(): array +{ + $cases = array_filter(Platform::cases(), fn (Platform $platform): bool => $platform !== Platform::X); + + return array_combine( + array_map(fn (Platform $platform): string => $platform->value, $cases), + array_map(fn (Platform $platform): array => [$platform], $cases), + ); +} + +beforeEach(function () { + config()->set('trypost.platforms.x.defuse_links', true); +}); + test('it strips html tags for plain text platforms', function () { $sanitizer = new ContentSanitizer; $result = $sanitizer->sanitize('

Hello world

', Platform::Instagram); @@ -41,12 +61,11 @@ expect($result)->toBe('Just plain text'); }); -test('it handles null-safe empty content', function () { - // The publisher handles null check, sanitizer should handle empty string +test('it returns an empty string for empty content on every platform', function (Platform $platform) { $sanitizer = new ContentSanitizer; - $result = $sanitizer->sanitize('', Platform::X); - expect($result)->toBe(''); -}); + + expect($sanitizer->sanitize('', $platform))->toBe(''); +})->with(Platform::cases()); test('it converts list items to dashes', function () { $sanitizer = new ContentSanitizer; @@ -106,3 +125,290 @@ $result = $sanitizer->sanitize('

Hey @durov and @TryPostBot

', Platform::Telegram); expect($result)->toBe('Hey @durov and @TryPostBot'); }); + +test('it defuses a bare link for x', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Check acme.com today', Platform::X); + expect($result)->toBe('Check acme(.)com today'); +}); + +test('it strips the scheme when defusing a link for x', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('New post: https://acme.com/post', Platform::X); + expect($result)->toBe('New post: acme(.)com/post'); +}); + +test('it strips www when defusing a link for x', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('See http://www.acme.com', Platform::X); + expect($result)->toBe('See acme(.)com'); +}); + +test('it defuses every dot of a multi level host for x', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Read blog.acme.com.br/x', Platform::X); + expect($result)->toBe('Read blog(.)acme(.)com(.)br/x'); +}); + +test('it keeps dots in the path when defusing for x', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('Download acme.com/file.pdf', Platform::X); + expect($result)->toBe('Download acme(.)com/file.pdf'); +}); + +test('it leaves text that only looks like a link untouched for x', function () { + $sanitizer = new ContentSanitizer; + $content = 'We run Node.js 3.5 here, e.g. in the final file.pdf.'; + expect($sanitizer->sanitize($content, Platform::X))->toBe($content); +}); + +test('it defuses every link in the same post for x', function () { + $sanitizer = new ContentSanitizer; + $result = $sanitizer->sanitize('First acme.com then other.dev/a', Platform::X); + expect($result)->toBe('First acme(.)com then other(.)dev/a'); +}); + +test('it does not defuse links for any platform other than x', function (Platform $platform) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize('Check acme.com', $platform))->toContain('acme.com'); +})->with(platformsThatKeepLinks()); + +test('it leaves links untouched for x when defusing is disabled', function () { + config()->set('trypost.platforms.x.defuse_links', false); + + $sanitizer = new ContentSanitizer; + expect($sanitizer->sanitize('Check acme.com', Platform::X))->toBe('Check acme.com'); +}); + +test('it defuses every host shape for x', function (string $input, string $expected) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize("See {$input} now", Platform::X)) + ->toBe("See {$expected} now"); +})->with([ + // Bare host, no subdomain + 'gTLD' => ['acme.com', 'acme(.)com'], + 'ccTLD' => ['acme.br', 'acme(.)br'], + 'new gTLD' => ['acme.dev', 'acme(.)dev'], + 'two-level TLD' => ['acme.com.br', 'acme(.)com(.)br'], + 'two-level TLD (uk)' => ['acme.co.uk', 'acme(.)co(.)uk'], + 'two-level TLD (au)' => ['acme.com.au', 'acme(.)com(.)au'], + 'two-level TLD (org.br)' => ['acme.org.br', 'acme(.)org(.)br'], + + // One subdomain + 'sub + gTLD' => ['blog.acme.com', 'blog(.)acme(.)com'], + 'sub + ccTLD' => ['blog.acme.io', 'blog(.)acme(.)io'], + 'sub + two-level TLD' => ['blog.acme.com.br', 'blog(.)acme(.)com(.)br'], + 'sub + two-level TLD (uk)' => ['blog.acme.co.uk', 'blog(.)acme(.)co(.)uk'], + + // Two subdomains + 'sub.sub + gTLD' => ['org.blog.acme.com', 'org(.)blog(.)acme(.)com'], + 'sub.sub + two-level TLD' => ['org.blog.acme.com.br', 'org(.)blog(.)acme(.)com(.)br'], + + // Three subdomains + 'sub.sub.sub + gTLD' => ['a.org.blog.acme.com', 'a(.)org(.)blog(.)acme(.)com'], + 'sub.sub.sub + two-level TLD' => ['a.org.blog.acme.com.br', 'a(.)org(.)blog(.)acme(.)com(.)br'], + + // Scheme is dropped + 'https + bare' => ['https://acme.com', 'acme(.)com'], + 'http + bare' => ['http://acme.com', 'acme(.)com'], + 'https + two-level TLD' => ['https://acme.com.br', 'acme(.)com(.)br'], + 'https + sub' => ['https://blog.acme.com', 'blog(.)acme(.)com'], + 'https + sub.sub + two-level TLD' => ['https://org.blog.acme.com.br', 'org(.)blog(.)acme(.)com(.)br'], + + // www is dropped, everything else defused + 'www + bare' => ['www.acme.com', 'acme(.)com'], + 'www + two-level TLD' => ['www.acme.com.br', 'acme(.)com(.)br'], + 'https + www + bare' => ['https://www.acme.com', 'acme(.)com'], + 'https + www + two-level TLD' => ['https://www.acme.com.br', 'acme(.)com(.)br'], + 'www + sub' => ['www.blog.acme.com', 'blog(.)acme(.)com'], + + // Path, query and fragment survive untouched + 'path' => ['acme.com/post', 'acme(.)com/post'], + 'nested path' => ['acme.com.br/blog/2026/x', 'acme(.)com(.)br/blog/2026/x'], + 'path with dot' => ['acme.com/file.pdf', 'acme(.)com/file.pdf'], + 'query string' => ['acme.com/a?b=c&d=e', 'acme(.)com/a?b=c&d=e'], + 'fragment' => ['acme.com/a#section', 'acme(.)com/a#section'], + 'https + sub.sub + two-level TLD + path + query + fragment' => [ + 'https://org.blog.acme.com.br/a/b?c=1#d', + 'org(.)blog(.)acme(.)com(.)br/a/b?c=1#d', + ], + + // Casing is preserved + 'uppercase host' => ['ACME.COM', 'ACME(.)COM'], + 'mixed case host' => ['Blog.Acme.Com.Br', 'Blog(.)Acme(.)Com(.)Br'], + + // Hyphens are valid in labels + 'hyphenated host' => ['my-blog.acme-corp.com.br', 'my-blog(.)acme-corp(.)com(.)br'], + + // Surrounding punctuation must not be swallowed + 'trailing period' => ['acme.com.', 'acme(.)com.'], + 'trailing comma' => ['acme.com,', 'acme(.)com,'], + 'wrapped in parentheses' => ['(acme.com)', '(acme(.)com)'], +]); + +test('it defuses several links of different shapes in one post for x', function () { + $sanitizer = new ContentSanitizer; + $content = 'Read https://blog.acme.com.br/x, see www.acme.io and acme.dev/docs'; + + expect($sanitizer->sanitize($content, Platform::X)) + ->toBe('Read blog(.)acme(.)com(.)br/x, see acme(.)io and acme(.)dev/docs'); +}); + +test('it leaves email addresses untouched for x', function () { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize('Email contact@acme.com.br today', Platform::X)) + ->toBe('Email contact@acme.com.br today'); +}); + +test('it leaves non-link text untouched for x', function (string $input) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize("About {$input} here", Platform::X)) + ->toBe("About {$input} here"); +})->with([ + 'js library' => ['Node.js'], + 'abbreviation' => ['e.g.'], + 'abbreviation (latin)' => ['i.e.'], + 'decimal number' => ['3.5'], + 'version number' => ['8.5.1'], + 'pdf file' => ['file.pdf'], + 'image file' => ['photo.png'], + 'ellipsis' => ['wait...'], +]); + +test('it keeps every host shape intact on every platform other than x', function (string $input) { + $sanitizer = new ContentSanitizer; + + foreach (platformsThatKeepLinks() as [$platform]) { + expect($sanitizer->sanitize("See {$input}", $platform))->toContain($input); + } +})->with([ + 'bare' => ['acme.com'], + 'two-level TLD' => ['acme.com.br'], + 'sub' => ['blog.acme.com'], + 'sub.sub + two-level TLD' => ['org.blog.acme.com.br'], + 'full url' => ['https://org.blog.acme.com.br/a?b=c'], +]); + +test('it returns the original content for x when the regex engine bails out', function (string $content) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize($content, Platform::X))->toBe($content); +})->with([ + 'catastrophic backtracking' => [str_repeat('a.a', 5000)], + 'invalid utf-8' => ["acme.com \xC3\x28 broken"], +]); + +test('it defuses the same content the same way when run again', function () { + $sanitizer = new ContentSanitizer; + $once = $sanitizer->sanitize('See https://acme.com/x now', Platform::X); + + expect($sanitizer->sanitize($once, Platform::X))->toBe($once); +}); + +test('it defuses a file name whose extension is a delegated tld for x', function (string $input, string $expected) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize("See {$input} here", Platform::X))->toBe("See {$expected} here"); +})->with([ + 'zip is a delegated tld' => ['backup.zip', 'backup(.)zip'], + 'mov is a delegated tld' => ['clip.mov', 'clip(.)mov'], + 'md is a delegated tld' => ['README.md', 'README(.)md'], +]); + +test('it defuses a url with an explicit scheme even when the tld is unknown', function () { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize('See https://intranet.acme.internal/x', Platform::X)) + ->toBe('See intranet(.)acme(.)internal/x'); +}); + +test('it leaves a bare host with an unknown tld untouched for x', function () { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize('See intranet.acme.internal here', Platform::X)) + ->toBe('See intranet.acme.internal here'); +}); + +test('it measures a platform limit against the rendered text, not the markup', function () { + $sanitizer = new ContentSanitizer; + $content = implode(' & ', array_fill(0, 900, 'a')); + + expect($sanitizer->displayText($content, Platform::Telegram))->toBe($content) + ->and(Platform::Telegram->contentOverflow($sanitizer->displayText($content, Platform::Telegram))) + ->toBe(0); +}); + +test('it counts the defused form for x because those characters are visible', function () { + $sanitizer = new ContentSanitizer; + config()->set('trypost.platforms.x.defuse_links', true); + + expect($sanitizer->displayText('acme.com', Platform::X))->toBe('acme(.)com'); +}); + +test('it defuses a url that carries userinfo for x', function () { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize('See https://user@acme.com/x now', Platform::X)) + ->toBe('See acme(.)com/x now'); +}); + +test('it still leaves a plain email address untouched for x', function () { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize('Email contact@acme.com.br today', Platform::X)) + ->toBe('Email contact@acme.com.br today'); +}); + +test('it defuses an internationalised host for x', function (string $input, string $expected) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize("See {$input} now", Platform::X))->toBe("See {$expected} now"); +})->with([ + 'unicode label, ascii tld' => ['café.com', 'café(.)com'], + 'unicode subdomain' => ['blog.café.com.br', 'blog(.)café(.)com(.)br'], + 'unicode tld' => ['пример.рф', 'пример(.)рф'], + 'punycode host' => ['acme.xn--p1ai', 'acme(.)xn--p1ai'], + 'unicode with scheme' => ['https://café.com/menü', 'café(.)com/menü'], +]); + +test('it defuses tlds written in scripts that use combining marks for x', function (string $input, string $expected) { + $sanitizer = new ContentSanitizer; + + expect($sanitizer->sanitize("See {$input} now", Platform::X))->toBe("See {$expected} now"); +})->with([ + 'devanagari' => ['उदाहरण.भारत', 'उदाहरण(.)भारत'], + 'sinhala' => ['උදාහරණ.ලංකා', 'උදාහරණ(.)ලංකා'], + 'tamil' => ['உதாரணம்.இந்தியா', 'உதாரணம்(.)இந்தியா'], +]); + +test('it does not decode entities twice when the platform already resolved them', function (Platform $platform) { + $sanitizer = new ContentSanitizer; + $content = 'Tom &amp; Jerry'; + + expect($sanitizer->displayText($content, $platform)) + ->toBe($sanitizer->sanitize($content, $platform)); +})->with([ + 'x' => [Platform::X], + 'linkedin' => [Platform::LinkedIn], + 'instagram' => [Platform::Instagram], +]); + +test('it resolves markup only for the platforms whose sanitized form carries it', function () { + $sanitizer = new ContentSanitizer; + $content = 'Tom & Jerry'; + + // Telegram escapes the ampersand for parse_mode=HTML; the reader still sees one. + expect($sanitizer->sanitize($content, Platform::Telegram))->toContain('&') + ->and($sanitizer->displayText($content, Platform::Telegram))->toBe($content); +}); + +test('it strips mastodon markup for length without decoding its entities twice', function () { + $sanitizer = new ContentSanitizer; + $content = '

Tom &amp; Jerry

'; + + expect($sanitizer->displayText($content, Platform::Mastodon))->toBe('Tom & Jerry'); +}); diff --git a/tests/Feature/Services/Social/DiscordPublisherTest.php b/tests/Feature/Services/Social/DiscordPublisherTest.php index 8168de09..075d9f53 100644 --- a/tests/Feature/Services/Social/DiscordPublisherTest.php +++ b/tests/Feature/Services/Social/DiscordPublisherTest.php @@ -309,3 +309,16 @@ function fakeDiscord(array $messageResponse = ['id' => '777'], int $status = 200 expect(fn () => $this->publisher->publish(($this->makePostPlatform)())) ->toThrow(DiscordPublishException::class); }); + +test('discord publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + fakeDiscord(); + + $this->publisher->publish(($this->makePostPlatform)()); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/messages') + && data_get($request->data(), 'content') === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 384b0b3c..1a1ab1ba 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -929,3 +929,16 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string && ! array_key_exists('alt_text_custom', $request->data()); }); }); + +test('facebook publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + Http::fake(['*/page_123/feed' => Http::response(['id' => 'page_123_post_456'], 200)]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/feed') + && $request['message'] === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 19501531..8715b18b 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -2005,3 +2005,33 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string && ! array_key_exists('alt_text', $data); }); }); + +test('instagram publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update([ + 'content' => 'New post: https://acme.com/blog', + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/test-image.jpg', + 'url' => 'https://example.com/media/2026-01/test-image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test.jpg', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-123456789'], 200), + 'https://graph.instagram.com/v25.0/media-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/ABC123/', + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/ig_123456789/media') + && ! str_contains($request->url(), 'media_publish') + && data_get($request->data(), 'caption') === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/LinkedInPagePublisherTest.php b/tests/Feature/Services/Social/LinkedInPagePublisherTest.php index 832881bf..52f3e65c 100644 --- a/tests/Feature/Services/Social/LinkedInPagePublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPagePublisherTest.php @@ -437,3 +437,20 @@ Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts')); }); + +test('linkedin page publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + Http::fake([ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ + 'x-restli-id' => 'urn:li:share:1234567890', + ]), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts') + && $request['commentary'] === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index fd00f16a..efa4347c 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -1145,3 +1145,20 @@ protected function processingPollSeconds(): int expect($result['id'])->toBe(''); expect($result['url'])->toBeNull(); }); + +test('linkedin publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://trypost.it/blog']); + + Http::fake([ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [ + 'x-restli-id' => 'urn:li:share:1234567890', + ]), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts') + && $request['commentary'] === 'New post: https://trypost.it/blog'); +}); diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php index 0cab6574..37f527d5 100644 --- a/tests/Feature/Services/Social/MastodonPublisherTest.php +++ b/tests/Feature/Services/Social/MastodonPublisherTest.php @@ -564,3 +564,19 @@ return str_contains($request->url(), 'mastodon.social'); }); }); + +test('mastodon publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + Http::fake(['https://mastodon.social/api/v1/statuses' => Http::response([ + 'id' => '109876543210', + 'url' => 'https://mastodon.social/@testuser/109876543210', + ], 200)]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/statuses') + && $request['status'] === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/PinterestPublisherTest.php b/tests/Feature/Services/Social/PinterestPublisherTest.php index 75aba56d..cb5cdccb 100644 --- a/tests/Feature/Services/Social/PinterestPublisherTest.php +++ b/tests/Feature/Services/Social/PinterestPublisherTest.php @@ -1306,3 +1306,28 @@ Http::assertNotSent(fn ($request) => str_contains($request->url(), '/v5/pins')); Sleep::assertNeverSlept(); }); + +test('pinterest publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update([ + 'content' => 'New post: https://acme.com/blog', + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', + ]], + ]); + + Http::fake([ + '*/v5/pins' => Http::response(['id' => 'pin_123456'], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v5/pins') + && $request['description'] === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/TelegramPublisherTest.php b/tests/Feature/Services/Social/TelegramPublisherTest.php index 7e50ceb5..e03f61f9 100644 --- a/tests/Feature/Services/Social/TelegramPublisherTest.php +++ b/tests/Feature/Services/Social/TelegramPublisherTest.php @@ -218,3 +218,16 @@ function telegramOk(array $result): array expect($result['url'])->toBe('https://t.me/c/9876543210/99'); }); + +test('telegram publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + Http::fake(['*/botTESTTOKEN/sendMessage' => Http::response(telegramOk(['message_id' => 42]), 200)]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/sendMessage') + && $request['text'] === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/ThreadsPublisherTest.php b/tests/Feature/Services/Social/ThreadsPublisherTest.php index 7bf19e3f..d0a2ce77 100644 --- a/tests/Feature/Services/Social/ThreadsPublisherTest.php +++ b/tests/Feature/Services/Social/ThreadsPublisherTest.php @@ -885,3 +885,23 @@ expect($result['id'])->toBe('video-123'); }); + +test('threads publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update(['content' => 'New post: https://acme.com/blog']); + + Http::fake([ + 'https://graph.threads.net/v1.0/123456789/threads' => Http::response(['id' => 'container-123'], 200), + 'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response(['id' => 'post-123456789'], 200), + 'https://graph.threads.net/v1.0/post-123456789*' => Http::response([ + 'permalink' => 'https://www.threads.net/@testuser/post/ABC123', + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/123456789/threads') + && ! str_contains($request->url(), 'threads_publish') + && data_get($request->data(), 'text') === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 49b728bb..8adeb204 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -1374,3 +1374,30 @@ expect($result['id'])->toBe('pub_cleanup_throws_123'); }); + +test('tiktok publisher keeps links intact', function () { + config()->set('trypost.platforms.x.defuse_links', true); + + $this->post->update([ + 'content' => 'New post: https://acme.com/blog', + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ]], + ]); + + Http::fake([ + $this->api.'/post/publish/video/init/' => Http::response(['data' => ['publish_id' => 'pub_123']], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_123'], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/video/init/') + && data_get($request->data(), 'post_info.title') === 'New post: https://acme.com/blog'); +}); diff --git a/tests/Feature/Services/Social/XLinkDefusingParityTest.php b/tests/Feature/Services/Social/XLinkDefusingParityTest.php new file mode 100644 index 00000000..3258962c --- /dev/null +++ b/tests/Feature/Services/Social/XLinkDefusingParityTest.php @@ -0,0 +1,45 @@ +run(); + + if (! $process->isSuccessful()) { + $this->markTestSkipped('node is unavailable: '.$process->getErrorOutput()); + } + + config()->set('trypost.platforms.x.defuse_links', true); + $sanitizer = new ContentSanitizer; + + $fromPhp = array_map(fn (string $entry): string => $sanitizer->sanitize($entry, Platform::X), $corpus); + $fromTypeScript = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + expect(array_combine($corpus, $fromTypeScript))->toEqual(array_combine($corpus, $fromPhp)); + + unlink($tldsPath); +}); diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index b3af28b6..8d050ea3 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -1166,3 +1166,64 @@ function isXMediaUploadStatusRequest(Request $request): bool expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(XPublishException::class, 'X rejected the attached media'); }); + +test('x publisher sends the tweet with links defused', function () { + config()->set('trypost.platforms.x.defuse_links', true); + $this->post->update(['content' => 'New post: https://trypost.it/blog']); + + Http::fake([ + config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '111']], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets') + && $request['text'] === 'New post: trypost(.)it/blog'); +}); + +test('x publisher leaves the tweet untouched when defusing is disabled', function () { + config()->set('trypost.platforms.x.defuse_links', false); + $this->post->update(['content' => 'New post: https://trypost.it/blog']); + + Http::fake([ + config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '111']], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets') + && $request['text'] === 'New post: https://trypost.it/blog'); +}); + +test('x publisher rejects a post that only fits before its links are defused', function () { + config()->set('trypost.platforms.x.defuse_links', true); + $this->post->update(['content' => str_repeat('a', 271).' acme.com']); + + Http::fake([config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '1']], 200)]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class, 'Content exceeds X limit of 280 characters (282 provided).'); + + Http::assertNothingSent(); +}); + +test('x publisher accepts a post that only fits once its links are defused', function () { + config()->set('trypost.platforms.x.defuse_links', true); + $this->post->update(['content' => str_repeat('a', 263).' https://acme.com/x']); + + Http::fake([config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '1']], 200)]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => mb_strlen($request['text']) === 276); +}); + +test('x publisher does not count html markup toward the character limit', function () { + $this->post->update(['content' => '

'.str_repeat('a', 275).'

']); + + Http::fake([config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '1']], 200)]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => mb_strlen($request['text']) === 275); +}); diff --git a/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php b/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php index f841553d..394daaba 100644 --- a/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php +++ b/tests/Unit/Rules/ContentFitsPlatformLimitsTest.php @@ -60,3 +60,33 @@ function runFitsRule(string $content, array $platforms): array expect($errors)->toBe([]); }); + +test('measures the defused length for x so a link post that will fit is accepted', function () { + config()->set('trypost.platforms.x.defuse_links', true); + $errors = runFitsRule(str_repeat('a', 263).' https://acme.com/x', [Platform::X]); + + expect($errors)->toBe([]); +}); + +test('measures the defused length for x so a link post that will not fit is rejected', function () { + config()->set('trypost.platforms.x.defuse_links', true); + $errors = runFitsRule(str_repeat('a', 271).' acme.com', [Platform::X]); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain('X')->toContain('280')->toContain('2'); +}); + +test('does not count html markup toward a platform cap', function () { + $errors = runFitsRule('

'.str_repeat('a', 275).'

', [Platform::X]); + + expect($errors)->toBe([]); +}); + +test('measures telegram against the rendered text, not the escaped markup', function () { + // 3.597 characters, under Telegram's 4.096 cap. Escaping every ampersand for + // parse_mode=HTML nearly doubles that, but the reader still sees one character. + $content = implode(' & ', array_fill(0, 900, 'a')); + + expect(mb_strlen($content))->toBeLessThan(Platform::Telegram->maxContentLength()) + ->and(runFitsRule($content, [Platform::Telegram]))->toBe([]); +}); diff --git a/tests/fixtures/defuse-x-links-harness.js b/tests/fixtures/defuse-x-links-harness.js new file mode 100644 index 00000000..321907b0 --- /dev/null +++ b/tests/fixtures/defuse-x-links-harness.js @@ -0,0 +1,24 @@ +/** + * Runs `resources/js/lib/defuseXLinks.ts` over a corpus and prints the results as + * JSON, so `tests/Feature/Services/Social/XLinkDefusingParityTest.php` can diff the + * TypeScript against the PHP it mirrors. Reads the source directly rather than + * importing it, which keeps the harness free of a bundler. + * + * The TLD set is handed in by the PHP side, exactly as the editor receives it as a + * page prop, so the corpus exercises the regex rather than a second copy of the list. + */ +import fs from 'node:fs'; + +const [tldsFile, defuserFile, corpusFile] = process.argv.slice(2); + +const tlds = new Set(JSON.parse(fs.readFileSync(tldsFile, 'utf8'))); +const pattern = new RegExp( + fs.readFileSync(defuserFile, 'utf8').match(/const LINK_PATTERN =\s*\/(.*)\/giu;/s)[1], + 'giu', +); + +const defuse = (content) => + content.replace(pattern, (whole, boundary, prefix, host, tld, path) => + prefix === '' && !tlds.has(tld.toLowerCase()) ? whole : boundary + host.replaceAll('.', '(.)') + path); + +console.log(JSON.stringify(JSON.parse(fs.readFileSync(corpusFile, 'utf8')).map(defuse))); diff --git a/tests/fixtures/x-link-corpus.json b/tests/fixtures/x-link-corpus.json new file mode 100644 index 00000000..29660d1f --- /dev/null +++ b/tests/fixtures/x-link-corpus.json @@ -0,0 +1,9 @@ +["acme.com","acme.com.br","blog.acme.com","blog.acme.com.br","org.blog.acme.com","org.blog.acme.com.br", +"https://acme.com/post","http://www.acme.com","www.acme.com","https://www.acme.com.br","acme.co.uk", +"acme.com/file.pdf","acme.com?a=b","acme.com:8080/x","acme.com/a#sec","https://user@acme.com/x", +"contato@acme.com","Node.js","e.g.","i.e.","3.5","8.5.1","file.pdf","photo.png","backup.zip","clip.mov", +"README.md","main.py","script.sh","café.com","blog.café.com.br","пример.рф","acme.xn--p1ai", +"उदाहरण.भारत","උදාහරණ.ලංකා","உதாரணம்.இந்தியா","ACME.COM","Blog.Acme.Com.Br","my-blog.acme-corp.com.br", +"acme.com.","acme.com,","(acme.com)","acme..com","a.co","-acme.com","acme.com5","acme.com-foo","1.23", +"🔥acme.com🔥","Read https://blog.acme.com.br/x, see www.acme.io and acme.dev/docs","","x","...", +"acme.com\nlinha2.com","https://acme.com/a?url=other.com","intranet.acme.internal","https://intranet.acme.internal/x"]