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})."""