Changeset bdb78e4 in Klonkt


Ignore:
Timestamp:
07/28/2026 08:59:00 AM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
2a76184
Parents:
b1512e0
Message:

Publiek oEmbed-register erbij: YouTube verstopt zijn tags voor een server

Waarom het op mijn machine werkte en op de VPS niet: YouTube serveert een
datacenter-IP een uitgeklede pagina. Titel leeg, nul meta property-tags, geen
oEmbed-link. Hun oEmbed-API antwoordt daar wel gewoon (status 200, echte titel).
Discovery via de pagina faalt dus precies waar het het meest uitmaakt.

Het publieke providerregister van oembed.com dicht dat gat, en dat is iets anders
dan de whitelist die we net weggehaald hebben: die lijst onderhouden wij niet, we
lezen er een die gepubliceerd wordt. Valt oembed.com weg, dan vallen we terug op
paginadiscovery en werkt alles gewoon minder slim.

Het register komt VOOR de paginafetch: het is een match in het geheugen plus een
klein API-verzoek, en scheelt dus ook de download van een pagina van een MB.
Eenmaal opgehaald blijft de lijst een dag staan; een stale lijst is beter dan
geen lijst.

Changed files:
src/services/EmbedResolver.js

  • matchProviderEndpoint (wildcard-schemes, host-fallback, {format} invullen)
  • oembedRequestUrl; registry-stap voor de pagina; liveIO cachet de lijst 24u

src/services/ActivityPubService.js

  • SELFHEAL_VERSION 19 -> 20

test/embed-resolver.test.js

  • 4 tests: schemes en host-fallback, url-opbouw, register wint van de pagina (en bespaart de download), en een onbereikbaar register valt netjes terug

remarks: 219 tests groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rb1512e0 rbdb78e4  
    28492849// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
    28502850// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
    2851 const SELFHEAL_VERSION = 19; // v19: head-read no longer stops on an og:image mention inside inline script
     2851const SELFHEAL_VERSION = 20; // v20: oEmbed provider registry, so platforms that hide their tags from a server still resolve
    28522852async function fetchNoteAP(url) {
    28532853  try {
  • src/services/EmbedResolver.js

    rb1512e0 rbdb78e4  
    5959  if (!image && !title) return null;
    6060  return { image: image && /^https?:\/\//i.test(image) ? image : null, title: title || null, site: meta['og:site_name'] || null };
     61}
     62
     63/**
     64 * Match a URL against the public oEmbed provider registry (oembed.com).
     65 *
     66 * Discovery through the page is the pure way, but it only works when the page
     67 * hands you its <link rel=oembed>, and big platforms do not always do that: from
     68 * a datacentre IP, YouTube serves a stripped page with no oEmbed link and no
     69 * OpenGraph at all, while its oEmbed API answers perfectly. The registry closes
     70 * that gap without us keeping a list of hosts: it is published and maintained by
     71 * oembed.com, we only read it.
     72 *
     73 * Pure, so the pattern matching is testable without a network.
     74 */
     75export function matchProviderEndpoint(url, providers) {
     76  if (!Array.isArray(providers)) return null;
     77  let host = '';
     78  try { host = new URL(url).host.replace(/^www\./, ''); } catch { return null; }
     79  const toRe = (scheme) => new RegExp('^' + String(scheme)
     80    .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
     81    .replace(/\*/g, '.*') + '$', 'i');
     82  for (const p of providers) {
     83    for (const ep of (p.endpoints || [])) {
     84      const target = typeof ep.url === 'string' ? ep.url.replace('{format}', 'json') : null;
     85      if (!target) continue;
     86      for (const scheme of (ep.schemes || [])) {
     87        if (toRe(scheme).test(url)) return target;
     88      }
     89      // No schemes listed: fall back to the provider's own host.
     90      if (!ep.schemes || !ep.schemes.length) {
     91        let phost = '';
     92        try { phost = new URL(p.provider_url).host.replace(/^www\./, ''); } catch { /* skip */ }
     93        if (phost && (host === phost || host.endsWith('.' + phost))) return target;
     94      }
     95    }
     96  }
     97  return null;
     98}
     99
     100/** Add the url + json format to an oEmbed endpoint. */
     101export function oembedRequestUrl(endpoint, url) {
     102  const sep = endpoint.includes('?') ? '&' : '?';
     103  return `${endpoint}${sep}format=json&url=${encodeURIComponent(url)}`;
    61104}
    62105
     
    136179  }
    137180
    138   // 2. oEmbed, then OpenGraph. One page fetch serves both: oEmbed is the richer
    139   //    protocol, OpenGraph is the one most of the web actually ships.
     181  // 2. The oEmbed registry: a cheap in-memory match, then one small API call.
     182  //    Tried before the page because it is far cheaper AND because the big
     183  //    platforms are exactly the ones that hide their tags from a server.
     184  if (io.registry && io.getJSON) {
     185    const providers = await io.registry().catch(() => null);
     186    const endpoint = matchProviderEndpoint(url, providers);
     187    if (endpoint) {
     188      const o = await io.getJSON(oembedRequestUrl(endpoint, url)).catch(() => null);
     189      const card = fromOEmbed(url, o);
     190      if (card) return card;
     191    }
     192  }
     193
     194  // 3. oEmbed via the page, then OpenGraph. One page fetch serves both: oEmbed
     195  //    is the richer protocol, OpenGraph is the one most of the web ships.
    140196  //
    141197  //    There is deliberately NO list of known providers here. A hardcoded list
     
    247303 * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
    248304 */
     305// The provider registry, fetched once and kept for a day. It is a public list
     306// maintained by oembed.com, not by us; if it is unreachable we simply fall back
     307// to page discovery, so nothing breaks, it just gets less clever.
     308const REGISTRY_URL = 'https://oembed.com/providers.json';
     309const REGISTRY_TTL = 24 * 60 * 60 * 1000;
     310let _registry = null;
     311let _registryAt = 0;
     312
    249313export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
    250314  return {
     315    registry: async () => {
     316      if (_registry && Date.now() - _registryAt < REGISTRY_TTL) return _registry;
     317      const body = await safeJsonText(safeFetch, REGISTRY_URL, 'application/json');
     318      if (!body) return _registry;                       // keep a stale list over none
     319      try { _registry = JSON.parse(body); _registryAt = Date.now(); } catch { /* keep the old one */ }
     320      return _registry;
     321    },
    251322    provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
    252323    getAP: async (u) => {
     
    273344}
    274345
    275 export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
     346export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, matchProviderEndpoint, oembedRequestUrl, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
  • test/embed-resolver.test.js

    rb1512e0 rbdb78e4  
    198198  assert.equal(findOpenGraph(got).image, 'https://x/real.png', 'reads past the decoy to the real tag');
    199199});
     200
     201// The public oEmbed registry (oembed.com). Not a list we maintain: we read one
     202// that is published. It exists because discovery through the page fails exactly
     203// where it matters most, from a server YouTube hands a stripped page to.
     204const PROVIDERS = [
     205  { provider_name: 'YouTube', provider_url: 'https://www.youtube.com/',
     206    endpoints: [{ schemes: ['https://*.youtube.com/watch*', 'https://youtu.be/*'], url: 'https://www.youtube.com/oembed' }] },
     207  { provider_name: 'Bare', provider_url: 'https://bare.example/', endpoints: [{ url: 'https://bare.example/oembed.{format}' }] },
     208];
     209
     210test('matchProviderEndpoint matches wildcard schemes and falls back to the host', async () => {
     211  const { matchProviderEndpoint } = await import('../src/services/EmbedResolver.js');
     212  assert.equal(matchProviderEndpoint('https://youtu.be/abc?is=x', PROVIDERS), 'https://www.youtube.com/oembed');
     213  assert.equal(matchProviderEndpoint('https://www.youtube.com/watch?v=abc', PROVIDERS), 'https://www.youtube.com/oembed');
     214  assert.equal(matchProviderEndpoint('https://bare.example/thing/1', PROVIDERS), 'https://bare.example/oembed.json',
     215    'no schemes listed, so the provider host decides, and {format} is filled in');
     216  assert.equal(matchProviderEndpoint('https://elders.example/x', PROVIDERS), null);
     217  assert.equal(matchProviderEndpoint('not a url', PROVIDERS), null);
     218});
     219
     220test('oembedRequestUrl appends url + format, keeping an existing query', async () => {
     221  const { oembedRequestUrl } = await import('../src/services/EmbedResolver.js');
     222  assert.ok(oembedRequestUrl('https://x/oembed', 'https://a/b?c=1').includes('format=json&url=https%3A%2F%2Fa%2Fb%3Fc%3D1'));
     223  assert.ok(oembedRequestUrl('https://x/oembed?k=1', 'https://a/b').startsWith('https://x/oembed?k=1&'));
     224});
     225
     226test('the registry is tried before the page, and the page is not fetched when it hits', async () => {
     227  let pageFetched = false;
     228  const r = await resolveEmbed('https://youtu.be/abc', io({
     229    registry: async () => PROVIDERS,
     230    getJSON: async () => OEMBED_JSON,
     231    getPage: async () => { pageFetched = true; return OG_PAGE; },
     232  }));
     233  assert.equal(r.kind, 'oembed');
     234  assert.equal(r.title, 'A talk');
     235  assert.ok(!pageFetched, 'a registry hit saves the whole page download');
     236});
     237
     238test('an unreachable registry just falls through to the page', async () => {
     239  const r = await resolveEmbed('https://lg.example/a', io({
     240    registry: async () => { throw new Error('offline'); },
     241    getPage: async () => OG_PAGE,
     242  }));
     243  assert.equal(r.kind, 'opengraph');
     244});
Note: See TracChangeset for help on using the changeset viewer.