From 7f6c083dce7a5482a5257c9a9e0b719189f19fcb Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 14 Aug 2026 02:27:05 +0700 Subject: [PATCH] Add oEmbed discovery and standard support Fixes #696 --- src/routes/embed.nim | 68 +++++++++++++++++++++++++++++------------ src/routes/status.nim | 6 +++- src/views/general.nim | 10 ++++-- src/views/oembed.nimf | 4 +-- tests/pyproject.toml | 2 +- tests/test_embed.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/routes/embed.nim b/src/routes/embed.nim index 5f3e6f3..d1531e4 100644 --- a/src/routes/embed.nim +++ b/src/routes/embed.nim @@ -8,24 +8,32 @@ import router_utils export api, embed, vdom, tweet, general, router_utils -proc parseTweetUrl*(url: string): tuple[username, id: string] = +proc parseTweetPath(path: string): tuple[username, id: string] = + let parts = path.split('/') + if parts.len >= 3 and parts[1] in ["status", "statuses"]: + let tweetId = parts[2].split('?')[0].split('#')[0] + if tweetId.len > 0 and tweetId.allCharsInSet(Digits): + return (parts[0], tweetId) + return ("", "") + +proc parseTweetUrl*(url: string; cfg: Config): tuple[username, id: string] = var path = url if path.startsWith("https://"): path = path[8..^1] elif path.startsWith("http://"): path = path[7..^1] - const prefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", - "www.twitter.com/", "www.x.com/"] - for prefix in prefixes: + const twitterPrefixes = ["twitter.com/", "x.com/", "mobile.twitter.com/", + "www.twitter.com/", "www.x.com/"] + + for prefix in twitterPrefixes: if path.startsWith(prefix): - path = path[prefix.len..^1] - let parts = path.split('/') - if parts.len >= 3 and parts[1] == "status": - let tweetId = parts[2].split('?')[0].split('#')[0] - if tweetId.len > 0 and tweetId.allCharsInSet(Digits): - return (parts[0], tweetId) - break + return parseTweetPath(path[prefix.len..^1]) + + let nitterPrefix = cfg.hostname & "/" + if path.startsWith(nitterPrefix): + return parseTweetPath(path[nitterPrefix.len..^1]) + return ("", "") proc createEmbedRouter*(cfg: Config) = @@ -68,11 +76,19 @@ proc createEmbedRouter*(cfg: Config) = resp Http404 get "/api/oembed": - let url = @"url" + responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) + + let + url = @"url" + format = @"format" + + if format.len > 0 and format != "json": + resp Http501, "Only JSON format is supported" + if url.len == 0: resp Http400, "Missing url parameter" - let (username, tweetId) = parseTweetUrl(url) + let (username, tweetId) = parseTweetUrl(url, cfg) if username.len == 0 or tweetId.len == 0: resp Http400, "Invalid tweet URL" @@ -81,20 +97,34 @@ proc createEmbedRouter*(cfg: Config) = resp Http404 let - embedUrl = getUrlPrefix(cfg) & "/" & username & "/status/" & tweetId & "/embed" + maxwidthParam = @"maxwidth" + maxwidth = if maxwidthParam.len > 0: + try: clamp(parseInt(maxwidthParam), 220, 550) + except ValueError: 550 + else: 550 + embedUrl = getUrlPrefix(cfg) & "/" & tweet.user.username & "/status/" & tweetId & "/embed" authorUrl = getUrlPrefix(cfg) & "/" & tweet.user.username + title = stripHtml(tweet.text) - responseHeaders().get.add(("Access-Control-Allow-Origin", "*")) - respJson %*{ + var response = %*{ "version": "1.0", "type": "rich", "provider_name": cfg.title, "provider_url": getUrlPrefix(cfg), + "title": title, "author_name": tweet.user.fullname, "author_url": authorUrl, "url": embedUrl, - "width": 550, - "height": nil, + "width": maxwidth, + "height": newJNull(), "cache_age": "3153600000", - "html": renderOembedIframe(embedUrl) + "html": renderOembedIframe(embedUrl, maxwidth) } + + if tweet.media.len > 0: + let thumbUrl = getUrlPrefix(cfg) & getPicUrl(tweet.media[0].getThumb) + response["thumbnail_url"] = %thumbUrl + response["thumbnail_width"] = %maxwidth + response["thumbnail_height"] = %maxwidth + + respJson response diff --git a/src/routes/status.nim b/src/routes/status.nim index 870b6d7..32a4447 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -66,9 +66,13 @@ proc createStatusRouter*(cfg: Config) = elif card.video.isSome(): images = @[card.video.get().thumb] + let + tweetUrl = getUrlPrefix(cfg) & "/" & conv.tweet.user.username & "/status/" & $conv.tweet.id + oembedUrl = getUrlPrefix(cfg) & "/api/oembed?url=" & encodeUrl(tweetUrl) + let html = renderConversation(conv, prefs, getPath() & "#m", sort) resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, - images=images, video=video) + images=images, video=video, oembed=oembedUrl) get "/@name/status/@id/history/?": cond '.' notin @"name" diff --git a/src/views/general.nim b/src/views/general.nim index 143da54..d979898 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; video=""; images: seq[string] = @[]; banner=""; ogTitle=""; - rss=""; alternate=""): VNode = + rss=""; alternate=""; oembed=""): VNode = let theme = prefs.theme.toTheme let ogType = @@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; if rss.len > 0: link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed") + if oembed.len > 0: + let oembedTitle = if titleText.len > 0: titleText else: "oEmbed" + link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle) + if prefs.hlsPlayback: script(src="/js/hls.min.js", `defer`="") script(src="/js/hlsPlayback.js?v=1", `defer`="") @@ -124,7 +128,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; titleText=""; desc=""; ogTitle=""; rss=""; video=""; images: seq[string] = @[]; banner=""; - twitterLink=""): string = + twitterLink=""; oembed=""): string = let twitterLink = if twitterLink.len > 0: twitterLink @@ -132,7 +136,7 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs; let node = buildHtml(html(lang="en")): renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle, - rss, twitterLink) + rss, twitterLink, oembed) let bodyClass = if prefs.stickyNav: "fixed-nav" else: "" body(class=bodyClass): diff --git a/src/views/oembed.nimf b/src/views/oembed.nimf index 3dc793b..4f7f947 100644 --- a/src/views/oembed.nimf +++ b/src/views/oembed.nimf @@ -1,7 +1,7 @@ #? stdtmpl(subsChar = '$', metaChar = '#') ## SPDX-License-Identifier: AGPL-3.0-only -#proc renderOembedIframe*(embedUrl: string): string = +#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string = # result = "" - + # result = result.strip() #end proc diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 4105166..1907e60 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -8,4 +8,4 @@ python = "^3.14" seleniumbase = "4.46.5" [tool.pytest.ini_options] -addopts = "--pls=eager --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --timeout_multiplier=5" +addopts = "--pls=eager --rcs --reruns=2 --only-rerun=timeout --only-rerun=Timeout --only-rerun=Connection --only-rerun=WebDriverException --timeout_multiplier=5" diff --git a/tests/test_embed.py b/tests/test_embed.py index c15d247..77b69e6 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -178,12 +178,83 @@ class OEmbedApiTest(BaseTestCase): resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=400') self.assertEqual(resp.status_code, 200) + def test_oembed_maxwidth_clamps_to_range(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=100') + data = resp.json() + self.assertEqual(data['width'], 220) + self.assertIn('max-width:220px', data['html']) + + def test_oembed_maxwidth_caps_at_550(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=9999') + data = resp.json() + self.assertEqual(data['width'], 550) + + def test_oembed_maxwidth_invalid_uses_default(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&maxwidth=abc') + data = resp.json() + self.assertEqual(data['width'], 550) + def test_oembed_author_url_present(self): resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') data = resp.json() self.assertIn('author_url', data) self.assertIn('elonmusk', data['author_url']) + def test_oembed_accepts_nitter_url(self): + nitter_url = f'{self.base_url}/elonmusk/status/1141367104702038016' + resp = requests.get(f'{self.base_url}/api/oembed?url={nitter_url}') + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn('html', data) + + def test_oembed_format_json_accepted(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=json') + self.assertEqual(resp.status_code, 200) + + def test_oembed_format_xml_returns_501(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}&format=xml') + self.assertEqual(resp.status_code, 501) + + def test_oembed_has_title(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIn('title', data) + self.assertIsInstance(data['title'], str) + self.assertGreater(len(data['title']), 0) + + def test_oembed_has_null_height(self): + resp = requests.get(f'{self.base_url}/api/oembed?url={self.tweet_url}') + data = resp.json() + self.assertIsNone(data['height']) + + +class OEmbedDiscoveryTest(BaseTestCase): + """Test oEmbed discovery link tags on tweet pages.""" + + def test_tweet_page_has_oembed_link_tag(self): + self.open_nitter('elonmusk/status/1141367104702038016') + self.assert_element_present('link[type="application/json+oembed"]') + + def test_oembed_link_tag_points_to_api(self): + resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + self.assertIn('application/json+oembed', resp.text) + self.assertIn('/api/oembed?url=', resp.text) + self.assertIn('1141367104702038016', resp.text) + + def test_oembed_discovery_roundtrip(self): + """Fetch a tweet page, extract oEmbed URL, call it, verify response.""" + import re + resp = requests.get('http://localhost:8080/elonmusk/status/1141367104702038016') + match = re.search( + r'type="application/json\+oembed"\s+href="([^"]*)"', resp.text) + self.assertIsNotNone(match, "No oEmbed discovery link found in page") + oembed_url = match.group(1).replace('&', '&') + oembed_resp = requests.get(oembed_url) + self.assertEqual(oembed_resp.status_code, 200) + data = oembed_resp.json() + self.assertEqual(data['type'], 'rich') + self.assertIn('html', data) + class VideoEmbedTest(BaseTestCase): """Test video embed route (/i/videos/tweet/{id})."""