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.
This commit is contained in:
parent
02425038aa
commit
6496588bbc
35 changed files with 1266 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
19
AGENTS.md
19
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.
|
||||
|
||||
|
|
|
|||
18
CLAUDE.md
18
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.
|
||||
|
|
|
|||
|
|
@ -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() : [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
211
app/Support/LinkTlds.php
Normal file
211
app/Support/LinkTlds.php
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Services\Social\ContentSanitizer;
|
||||
|
||||
/**
|
||||
* Every top-level domain delegated in the root zone, in each form it can appear in
|
||||
* a post: ASCII, punycode (`xn--p1ai`) and the Unicode it decodes to (`рф`). This
|
||||
* mirrors the list X's own link detection uses, and that parity is the point — a
|
||||
* post X turns into a link is exactly a post X bills at the link rate, so a
|
||||
* hand-picked subset would leave {@see ContentSanitizer}
|
||||
* paying for its own gaps.
|
||||
*
|
||||
* Regenerate when TLDs are added or retired. Source of truth:
|
||||
* https://data.iana.org/TLD/tlds-alpha-by-domain.txt — lowercase every entry and,
|
||||
* for each `xn--` one, add `idn_to_utf8()` of it alongside.
|
||||
*
|
||||
* Snapshot: Version 2026082900, Last Updated Sat Aug 29 07:07:01 2026 UTC
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
class LinkTlds
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
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<int, string>
|
||||
*/
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
:data-testid="`content-counter-${limit.platform}`"
|
||||
class="inline-flex items-center gap-1.5 rounded-full border-2 px-2 py-1 text-[11px] font-bold leading-none tabular-nums shadow-2xs transition-colors"
|
||||
:class="limitClass(limit.state)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<Props>();
|
||||
|
||||
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(() => {
|
|||
<component
|
||||
:is="previewComponent"
|
||||
:social-account="resolvedSocialAccount"
|
||||
:content="content"
|
||||
:content="previewContent"
|
||||
:media="media"
|
||||
:content-type="contentType"
|
||||
:meta="meta"
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ const { card: linkCard, loading: linkCardLoading } = useLinkCard(
|
|||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-if="content" class="mt-3 text-[17px] text-[#0f1419] dark:text-[#e7e9ea] whitespace-pre-wrap leading-[22px]">
|
||||
<div v-if="content" data-testid="x-preview-content" class="mt-3 text-[17px] text-[#0f1419] dark:text-[#e7e9ea] whitespace-pre-wrap leading-[22px]">
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
27
resources/js/composables/useXLinkDefuser.ts
Normal file
27
resources/js/composables/useXLinkDefuser.ts
Normal file
|
|
@ -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<ReadonlySet<string>>(
|
||||
() => new Set((page.props.xLinkTlds as string[] | undefined) ?? []),
|
||||
);
|
||||
|
||||
const contentFor = (content: string, platform: string): string =>
|
||||
contentForPlatform(content, platform, tlds.value);
|
||||
|
||||
return { contentFor };
|
||||
};
|
||||
39
resources/js/lib/defuseXLinks.ts
Normal file
39
resources/js/lib/defuseXLinks.ts
Normal file
|
|
@ -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>): 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>,
|
||||
): string => (platform === Platform.X && tlds.size > 0 ? defuseXLinks(content, tlds) : content);
|
||||
101
tests/Browser/XLinkDefusingTest.php
Normal file
101
tests/Browser/XLinkDefusingTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
/**
|
||||
* The editor counts characters and renders the X preview client-side, mirroring
|
||||
* `ContentSanitizer` in TypeScript. These drive the real editor so that mirror is
|
||||
* covered by something other than a promise to keep it in step.
|
||||
*/
|
||||
function seedXDefusingPost(string $content): Post
|
||||
{
|
||||
$user = User::factory()->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(<<<JS
|
||||
(async () => {
|
||||
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();
|
||||
});
|
||||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, array<int, Platform>>
|
||||
*/
|
||||
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('<p>Hello <strong>world</strong></p>', 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('<p>Hey @durov and @TryPostBot</p>', 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 = '<p>Tom &amp; <strong>Jerry</strong></p>';
|
||||
|
||||
expect($sanitizer->displayText($content, Platform::Mastodon))->toBe('Tom & Jerry');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
45
tests/Feature/Services/Social/XLinkDefusingParityTest.php
Normal file
45
tests/Feature/Services/Social/XLinkDefusingParityTest.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Services\Social\ContentSanitizer;
|
||||
use App\Support\LinkTlds;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* The editor mirrors `ContentSanitizer`'s X branch in TypeScript so it can count
|
||||
* characters and preview without a round trip. PCRE and the JavaScript engine are
|
||||
* not the same, so agreement is asserted rather than assumed: both run the same
|
||||
* corpus, over the same TLD set, and every result must match.
|
||||
*/
|
||||
test('the typescript defuser agrees with the php one on every corpus entry', function () {
|
||||
$corpusPath = base_path('tests/fixtures/x-link-corpus.json');
|
||||
$corpus = json_decode(file_get_contents($corpusPath), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$tldsPath = tempnam(sys_get_temp_dir(), 'tlds').'.json';
|
||||
file_put_contents($tldsPath, json_encode(LinkTlds::all(), JSON_THROW_ON_ERROR));
|
||||
|
||||
$process = new Process([
|
||||
'node',
|
||||
base_path('tests/fixtures/defuse-x-links-harness.js'),
|
||||
$tldsPath,
|
||||
resource_path('js/lib/defuseXLinks.ts'),
|
||||
$corpusPath,
|
||||
]);
|
||||
$process->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);
|
||||
});
|
||||
|
|
@ -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' => '<p>'.str_repeat('a', 275).'</p>']);
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('<p>'.str_repeat('a', 275).'</p>', [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([]);
|
||||
});
|
||||
|
|
|
|||
24
tests/fixtures/defuse-x-links-harness.js
vendored
Normal file
24
tests/fixtures/defuse-x-links-harness.js
vendored
Normal file
|
|
@ -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)));
|
||||
9
tests/fixtures/x-link-corpus.json
vendored
Normal file
9
tests/fixtures/x-link-corpus.json
vendored
Normal file
|
|
@ -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"]
|
||||
Loading…
Reference in a new issue