Chat+ link previews: players for Vimeo and SoundCloud too, image links show the image
The YouTube-only field becomes a general embed address, made natively only for the frame hosts X's chat CSP allows (www.youtube.com, player.vimeo.com, w.soundcloud.com) and re-checked in the page. A link to an image previews the image itself. YouTube's thumbnail comes as data.
This commit is contained in:
parent
6d1798038c
commit
e962fadcd5
7 changed files with 115 additions and 32 deletions
|
|
@ -752,7 +752,7 @@
|
|||
"plugin.chat-plus.bookmarks.imported": "{count, plural, =0 {Nothing new to import} one {# starred message imported} other {# starred messages imported}}",
|
||||
"plugin.chat-plus.bookmarks.close": "Close",
|
||||
"plugin.chat-plus.feature.linkPreviews.label": "Link previews",
|
||||
"plugin.chat-plus.feature.linkPreviews.description": "Shows a preview under a link in a chat: the page's title, text and picture, and for YouTube the video, playable in place. To make one, the app asks the link's site for its preview itself: only the link is sent, without cookies, never the message or who sent it. Nothing is saved.",
|
||||
"plugin.chat-plus.feature.linkPreviews.description": "Shows a preview under a link in a chat: the page's title, text and picture, an image link's image, and for YouTube, Vimeo and SoundCloud the video or track, playable in place. To make one, the app asks the link's site for its preview itself: only the link is sent, without cookies, never the message or who sent it. Nothing is saved.",
|
||||
"plugin.chat-plus.linkPreviews.play": "Play the video here",
|
||||
"plugin.chat-plus.linkPreviews.open": "Open the link",
|
||||
"plugin.chat-plus.feature.searchLoaded.label": "Find in conversation",
|
||||
|
|
|
|||
|
|
@ -801,7 +801,7 @@
|
|||
"plugin.chat-plus.personFilter.nobody": "Henüz seçilecek kimse yok: hiç mesaj yüklenmedi.",
|
||||
"plugin.chat-plus.personFilter.hint": "Şimdiye kadar yüklenen mesajları yazan kişiler. Daha fazlası için yukarı kaydır.",
|
||||
"plugin.chat-plus.feature.linkPreviews.label": "Link önizlemeleri",
|
||||
"plugin.chat-plus.feature.linkPreviews.description": "Sohbetteki bir linkin altında önizlemesini gösterir: sayfanın başlığı, metni ve görseli; YouTube'da ise yerinde oynatılabilen video. Önizleme için uygulama linkin sitesine kendisi sorar: yalnızca link gider, çerezsiz; mesaj ya da gönderen asla gitmez. Hiçbir şey kaydedilmez.",
|
||||
"plugin.chat-plus.feature.linkPreviews.description": "Sohbetteki bir linkin altında önizlemesini gösterir: sayfanın başlığı, metni ve görseli; görsel linklerinde görselin kendisi; YouTube, Vimeo ve SoundCloud'da ise yerinde oynatılabilen video ya da parça. Önizleme için uygulama linkin sitesine kendisi sorar: yalnızca link gider, çerezsiz; mesaj ya da gönderen asla gitmez. Hiçbir şey kaydedilmez.",
|
||||
"plugin.chat-plus.linkPreviews.play": "Videoyu burada oynat",
|
||||
"plugin.chat-plus.linkPreviews.open": "Linki aç",
|
||||
"plugin.chat-plus.feature.keywords.label": "Sohbetlerde anahtar kelimeler",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Chat+ link previews (owner-approved network use, 2026-10-10): X's web chat shows a link as a bare
|
||||
//! card (an icon and the domain), so an X tab on a chat page may ask native code for the preview of
|
||||
//! ONE link it shows. Native code fetches that link's page head (or YouTube's oEmbed) itself:
|
||||
//! ONE link it shows. Native code fetches that link's page head (an image link: the image; YouTube:
|
||||
//! its oEmbed and thumbnail) itself:
|
||||
//!
|
||||
//! - https only, no user info, default port, a domain name (no IP literal), not X's own hosts;
|
||||
//! - every address the name resolves to must be public (no loopback, private, link-local, CGNAT,
|
||||
|
|
@ -12,7 +13,9 @@
|
|||
//! PNG/JPEG/GIF/WebP by its magic bytes only, handed back as a `data:` URL;
|
||||
//! - the request carries the link and nothing else: no conversation, sender or message text.
|
||||
//!
|
||||
//! Results live in memory only (a bounded cache) and are never written anywhere.
|
||||
//! Results live in memory only (a bounded cache) and are never written anywhere. A link to YouTube,
|
||||
//! Vimeo or SoundCloud also gets a player address on one of the hosts X's own CSP allows as a frame
|
||||
//! ([`embed_for`]); the page loads it only when the user presses play.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::net::IpAddr;
|
||||
|
|
@ -27,8 +30,8 @@ pub const MAX_DESCRIPTION: usize = 300;
|
|||
pub const MAX_SITE: usize = 80;
|
||||
pub const USER_AGENT: &str = "Mozilla/5.0 (compatible; wayBetter-LinkPreview/1.0; +https://waybetter.fyi/x/)";
|
||||
|
||||
/// What a chat tab gets back: plain text and, for a page, an image as a `data:` URL. A YouTube link
|
||||
/// also carries its video id (the page offers YouTube's own player, a www.youtube.com frame X allows).
|
||||
/// What a chat tab gets back: plain text, an image as a `data:` URL, and for a video or audio link the
|
||||
/// address of its site's own player ([`embed_for`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkPreview {
|
||||
|
|
@ -39,7 +42,33 @@ pub struct LinkPreview {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub youtube: Option<String>,
|
||||
pub embed: Option<String>,
|
||||
}
|
||||
|
||||
/// The site's own player for a link, on a host X's chat page allows as a frame (its CSP frame-src:
|
||||
/// www.youtube.com, player.vimeo.com, w.soundcloud.com); None for anything else.
|
||||
pub fn embed_for(url: &Url) -> Option<String> {
|
||||
if let Some(id) = youtube_id(url) {
|
||||
return Some(format!("https://www.youtube.com/embed/{id}?autoplay=1&rel=0"));
|
||||
}
|
||||
let host = url.host_str()?.trim_start_matches("www.").trim_start_matches("m.").to_ascii_lowercase();
|
||||
let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
|
||||
match host.as_str() {
|
||||
"vimeo.com" => {
|
||||
let id = segments.first()?;
|
||||
(segments.len() == 1 && (6..=12).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| format!("https://player.vimeo.com/video/{id}?autoplay=1"))
|
||||
}
|
||||
"soundcloud.com" => {
|
||||
const NOT_CONTENT: [&str; 9] = ["discover", "search", "you", "upload", "settings", "stream", "messages", "notifications", "pages"];
|
||||
let safe = |s: &&str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
|
||||
if segments.is_empty() || segments.len() > 3 || NOT_CONTENT.contains(&segments[0]) || !segments.iter().all(safe) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("https://w.soundcloud.com/player/?url=https%3A%2F%2Fsoundcloud.com%2F{}&auto_play=true&visual=true", segments.join("%2F")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// X's chat pages (`/i/chat`, `/messages`): the only pages that may ask for a preview.
|
||||
|
|
@ -357,7 +386,7 @@ mod fetch {
|
|||
}
|
||||
let result = match youtube_id(&url) {
|
||||
Some(id) => youtube(&url, id).await,
|
||||
None => page(&url).await,
|
||||
None => page(&url).await.map(|preview| LinkPreview { embed: embed_for(&url), ..preview }),
|
||||
};
|
||||
let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.len() >= CACHE_ENTRIES {
|
||||
|
|
@ -451,7 +480,13 @@ mod fetch {
|
|||
}
|
||||
|
||||
async fn page(url: &Url) -> Result<LinkPreview, String> {
|
||||
let (final_url, mime, body) = get(url, "text/html,application/xhtml+xml;q=0.9", MAX_PAGE_BYTES, head_complete).await?;
|
||||
let (final_url, mime, body) = get(url, "text/html,application/xhtml+xml;q=0.9,image/*;q=0.8", MAX_PAGE_BYTES, head_complete).await?;
|
||||
if mime.starts_with("image/") {
|
||||
// A link to an image: the image itself, named by its file.
|
||||
let name = final_url.path_segments().and_then(|mut s| s.next_back()).unwrap_or_default();
|
||||
let name = url::form_urlencoded::parse(name.as_bytes()).map(|(k, v)| if v.is_empty() { k.into_owned() } else { format!("{k}={v}") }).collect::<String>();
|
||||
return Ok(LinkPreview { url: url.to_string(), site: display_host(&final_url), title: clean(&name, MAX_TITLE), description: String::new(), image: Some(fetch_image(&final_url).await?), embed: None });
|
||||
}
|
||||
if !(mime.starts_with("text/html") || mime.starts_with("application/xhtml")) {
|
||||
return Err("UNSUPPORTED: not a web page".into());
|
||||
}
|
||||
|
|
@ -470,7 +505,7 @@ mod fetch {
|
|||
title: meta.title,
|
||||
description: meta.description,
|
||||
image,
|
||||
youtube: None,
|
||||
embed: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -499,7 +534,7 @@ mod fetch {
|
|||
title: text("title", MAX_TITLE),
|
||||
description: text("author_name", MAX_SITE),
|
||||
image: fetch_image(&thumbnail).await.ok(),
|
||||
youtube: Some(id),
|
||||
embed: embed_for(url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -568,6 +603,22 @@ mod tests {
|
|||
assert_eq!(id("https://example.com/watch?v=dQw4w9WgXcQ"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_only_on_the_hosts_x_allows_as_frames() {
|
||||
let embed = |s: &str| embed_for(&Url::parse(s).unwrap());
|
||||
assert_eq!(embed("https://youtu.be/dQw4w9WgXcQ").as_deref(), Some("https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0"));
|
||||
assert_eq!(embed("https://vimeo.com/76979871").as_deref(), Some("https://player.vimeo.com/video/76979871?autoplay=1"));
|
||||
assert_eq!(embed("https://vimeo.com/channels/staffpicks"), None);
|
||||
assert_eq!(
|
||||
embed("https://soundcloud.com/crtkafa/sets/albums").as_deref(),
|
||||
Some("https://w.soundcloud.com/player/?url=https%3A%2F%2Fsoundcloud.com%2Fcrtkafa%2Fsets%2Falbums&auto_play=true&visual=true")
|
||||
);
|
||||
assert_eq!(embed("https://soundcloud.com/discover"), None);
|
||||
assert_eq!(embed("https://soundcloud.com/a%22b"), None);
|
||||
assert_eq!(embed("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT"), None, "not a frame X allows");
|
||||
assert_eq!(embed("https://example.com/video"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_page_head() {
|
||||
let base = Url::parse("https://fishies.lol/play/").unwrap();
|
||||
|
|
@ -605,3 +656,20 @@ mod tests {
|
|||
assert_eq!(image_mime(b"<html>"), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live check against real sites (network): `LINK_PREVIEW_LIVE="url url" cargo test --lib live_previews -- --ignored --nocapture`.
|
||||
#[cfg(all(test, feature = "desktop"))]
|
||||
mod live {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn live_previews() {
|
||||
let urls = std::env::var("LINK_PREVIEW_LIVE").unwrap_or_default();
|
||||
for url in urls.split_whitespace() {
|
||||
let result = tauri::async_runtime::block_on(super::preview(url));
|
||||
match result {
|
||||
Ok(p) => println!("OK {url}\n site={:?} title={:?}\n desc={:?}\n image={} embed={:?}", p.site, p.title, p.description.chars().take(80).collect::<String>(), p.image.as_ref().map(|i| format!("{} bytes", i.len())).unwrap_or("none".into()), p.embed),
|
||||
Err(e) => println!("ERR {url}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Chat+ · Link previews. X's web chat shows a link as a bare card (an icon and the domain). For a
|
||||
// link in a message that is on screen, this asks native code (`link_preview`, one link at a time;
|
||||
// src-tauri/src/link_preview.rs) for its preview and shows it under X's card: the page's title,
|
||||
// description and image, or for YouTube the video's thumbnail with a play button that opens
|
||||
// YouTube's own player in place (www.youtube.com is in X's own frame-src).
|
||||
// src-tauri/src/link_preview.rs) for its preview and shows it under the message: any page's title,
|
||||
// description and picture, an image link's image, and for YouTube, Vimeo and SoundCloud a play
|
||||
// button that opens the site's own player in place (hosts X's own CSP allows as frames).
|
||||
//
|
||||
// Owner-approved network use (2026-10-10). Only the link leaves the page: never the message text,
|
||||
// the sender or the conversation. Nothing is stored: answers are kept in memory for this page only.
|
||||
|
|
@ -13,13 +13,20 @@ import type { PluginApi } from '../../inject/pluginApi.ts';
|
|||
import { clearAttribute } from './ui.ts';
|
||||
import { CONVERSATION_PROBES, CHAT_PAGE_PROBE, chatRoot, chatSelectors, renderedRows } from './x.ts';
|
||||
|
||||
export type LinkPreview = { url: string; site: string; title: string; description: string; image?: string; youtube?: string };
|
||||
export type LinkPreview = { url: string; site: string; title: string; description: string; image?: string; embed?: string };
|
||||
type Invoke = (command: 'link_preview', args: { url: string }) => Promise<unknown>;
|
||||
|
||||
let bridge: Invoke | null = null;
|
||||
/** The runtime hands over its native bridge (src/inject/entry.tsx); without one the feature shows nothing. */
|
||||
export function setLinkPreviewBridge(invoke: Invoke | null | undefined): void { bridge = invoke ?? null; }
|
||||
|
||||
/** The players a preview may open: the sites' own, on hosts X's chat page allows as frames. */
|
||||
const PLAYERS = [
|
||||
/^https:\/\/www\.youtube\.com\/embed\/[A-Za-z0-9_-]{11}\?autoplay=1&rel=0$/,
|
||||
/^https:\/\/player\.vimeo\.com\/video\/\d{6,12}\?autoplay=1$/,
|
||||
/^https:\/\/w\.soundcloud\.com\/player\/\?url=https%3A%2F%2Fsoundcloud\.com(?:%2F[A-Za-z0-9_-]+){1,3}&auto_play=true&visual=true$/,
|
||||
];
|
||||
|
||||
const X_HOSTS = /(^|\.)(x\.com|twitter\.com|t\.co|twimg\.com|x\.co)$/i;
|
||||
|
||||
/** A link worth a preview: plain https, a public-looking host name, not X's own. */
|
||||
|
|
@ -41,11 +48,11 @@ export function asPreview(value: unknown): LinkPreview | null {
|
|||
const url = previewable(str('url', 2048));
|
||||
if (!url) return null;
|
||||
const image = str('image', 3_000_000);
|
||||
const youtube = str('youtube', 11);
|
||||
const embed = str('embed', 400);
|
||||
const preview: LinkPreview = { url, site: str('site', 80), title: str('title', 200), description: str('description', 300) };
|
||||
if (/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) preview.image = image;
|
||||
if (/^[A-Za-z0-9_-]{11}$/.test(youtube)) preview.youtube = youtube;
|
||||
return preview.title || preview.image || preview.youtube ? preview : null;
|
||||
if (PLAYERS.some(player => player.test(embed))) preview.embed = embed;
|
||||
return preview.title || preview.image || preview.embed ? preview : null;
|
||||
}
|
||||
|
||||
const CARD = 'bt-chat-lp';
|
||||
|
|
@ -121,12 +128,15 @@ export function renderCard(doc: Document, preview: LinkPreview, words: { play: s
|
|||
card.rel = 'noopener noreferrer';
|
||||
card.setAttribute(FOR, preview.url);
|
||||
card.title = words.open;
|
||||
if (preview.youtube) {
|
||||
if (preview.embed) {
|
||||
const embed = preview.embed;
|
||||
const media = el(doc, 'span', `${CARD}-media is-video`);
|
||||
const thumb = el(doc, 'img', '');
|
||||
thumb.src = preview.image ?? `https://i.ytimg.com/vi/${preview.youtube}/hqdefault.jpg`;
|
||||
thumb.alt = '';
|
||||
thumb.referrerPolicy = 'no-referrer';
|
||||
if (preview.image) {
|
||||
const thumb = el(doc, 'img', '');
|
||||
thumb.src = preview.image;
|
||||
thumb.alt = '';
|
||||
media.append(thumb);
|
||||
}
|
||||
const play = el(doc, 'button', `${CARD}-play`);
|
||||
play.type = 'button';
|
||||
play.setAttribute('aria-label', words.play);
|
||||
|
|
@ -140,16 +150,16 @@ export function renderCard(doc: Document, preview: LinkPreview, words: { play: s
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const frame = el(doc, 'iframe', '');
|
||||
frame.src = `https://www.youtube.com/embed/${preview.youtube}?autoplay=1&rel=0`;
|
||||
frame.src = embed;
|
||||
frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
||||
frame.allowFullscreen = true;
|
||||
frame.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
frame.title = preview.title || 'YouTube';
|
||||
frame.title = preview.title || preview.site;
|
||||
media.replaceChildren(frame);
|
||||
card.removeAttribute('href');
|
||||
card.style.cursor = 'default';
|
||||
});
|
||||
media.append(thumb, play);
|
||||
media.append(play);
|
||||
card.append(media);
|
||||
} else if (preview.image) {
|
||||
const media = el(doc, 'span', `${CARD}-media`);
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ test('link previews: a native answer is checked again: plain fields, capped, dat
|
|||
assert.equal(good.image, 'data:image/png;base64,iVBORw0KGgo=');
|
||||
assert.equal(asPreview({ url: 'https://a.example/', title: 't', image: 'data:image/svg+xml;base64,PHN2Zz4=' })?.image, undefined, 'no SVG');
|
||||
assert.equal(asPreview({ url: 'https://a.example/', title: 't', image: 'https://tracker.example/p.gif' })?.image, undefined, 'no remote image');
|
||||
assert.equal(asPreview({ url: 'https://youtu.be/x', title: 't', youtube: 'dQw4w9WgXcQ' })?.youtube, 'dQw4w9WgXcQ');
|
||||
assert.equal(asPreview({ url: 'https://youtu.be/x', title: 't', youtube: 'a"onload="x' })?.youtube, undefined);
|
||||
for (const embed of [
|
||||
'https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0',
|
||||
'https://player.vimeo.com/video/76979871?autoplay=1',
|
||||
'https://w.soundcloud.com/player/?url=https%3A%2F%2Fsoundcloud.com%2Fcrtkafa%2Fsets%2Falbums&auto_play=true&visual=true',
|
||||
]) assert.equal(asPreview({ url: 'https://a.example/', title: 't', embed })?.embed, embed, embed);
|
||||
for (const embed of ['https://evil.example/embed', 'https://www.youtube.com/embed/a"onload="x', 'javascript:alert(1)', 'https://open.spotify.com/embed/track/1'])
|
||||
assert.equal(asPreview({ url: 'https://a.example/', title: 't', embed })?.embed, undefined, embed);
|
||||
assert.equal(asPreview({ url: 'javascript:alert(1)', title: 't' }), null, 'the link itself is re-checked');
|
||||
assert.equal(asPreview({ url: 'https://a.example/', title: '' }), null, 'nothing to show, no card');
|
||||
assert.equal(asPreview('nope'), null);
|
||||
|
|
@ -39,10 +44,10 @@ test('link previews: X\'s bare card (a host name and an icon) is replaced; a car
|
|||
assert.equal(isBareCard(anchor('')), false);
|
||||
});
|
||||
|
||||
test('link previews: page text only through textContent, the player only on YouTube\'s host X allows, no request from the page', () => {
|
||||
test('link previews: page text only through textContent, players only on the hosts X allows as frames, no request from the page', () => {
|
||||
const source = readFileSync(new URL('../src/areas/chat/linkPreviews.ts', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
|
||||
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|WebSocket|sendBeacon/);
|
||||
for (const match of source.matchAll(/https:\/\/([a-z0-9.-]+)\//g)) assert.ok(['i.ytimg.com', 'www.youtube.com'].includes(match[1]!), match[1]);
|
||||
assert.doesNotMatch(source, /i\.ytimg\.com/, 'thumbnails come as data from native code');
|
||||
assert.match(source, /rel = 'noopener noreferrer'/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue