diff --git a/apps/web/test/openwebring.test.js b/apps/web/test/openwebring.test.js index 70c3211..4610d74 100644 --- a/apps/web/test/openwebring.test.js +++ b/apps/web/test/openwebring.test.js @@ -254,6 +254,30 @@ test('a descriptor naming the ring counts as linking back', () => { assert.equal(descriptorUrlFor('not a url'), null); }); +test('a member on a shared host points at its descriptor from the page, and the check follows it', async () => { + // A blog under /~name/ cannot put a file at the host's /.well-known/, so its + // page says where the file is (the spec's second discovery route). The + // href is relative and the rel is one of several. + const site = 'https://host.example/~name/blog/'; + const page = `next`; + const asked = []; + const fetchText = async (url, opts) => { + asked.push(url); + if (url === site) return page; + if (url === 'https://host.example/~name/blog/openwebring.json') return JSON.stringify({ made_by: 'both', disclosure: 'ai-assisted', rings: [] }); + return null; + }; + const found = await checkRingMember({ base: BASE, ringSlug: 'physics', memberUrl: site, fetchText }); + assert.deepEqual(found, { status: 'active', linked: 'page', reachable: true, madeBy: 'both', disclosure: 'ai-assisted', descriptorUrl: 'https://host.example/~name/blog/openwebring.json' }); + assert.deepEqual(asked, [site, 'https://host.example/.well-known/openwebring.json', 'https://host.example/~name/blog/openwebring.json'], 'the origin first, then the page\'s own pointer'); + + // No pointer and no well-known file: nothing is fetched a third time. + const bare = []; + const none = await checkRingMember({ base: BASE, ringSlug: 'physics', memberUrl: site, fetchText: async (url) => { bare.push(url); return url === site ? '

plain

' : null; } }); + assert.equal(none.madeBy, null); + assert.equal(bare.length, 2); +}); + test('checking a member asks two URLs and decides from both', async () => { const page = `next`; const descriptor = JSON.stringify({ made_by: 'both', disclosure: 'ai-assisted', rings: [] }); diff --git a/packages/ingest/src/webring.js b/packages/ingest/src/webring.js index 2469756..8dd263f 100644 --- a/packages/ingest/src/webring.js +++ b/packages/ingest/src/webring.js @@ -80,6 +80,35 @@ export function linksToRing(html, base, slug) { * @param {string} siteUrl * @returns {string|null} */ +/** + * The descriptor a page points at with , + * resolved against the page, or null. The spec's second discovery route: + * a member whose site is a path on a shared host (a user directory, a blog + * under /~name/) cannot put a file at that host's /.well-known/, so the + * page says where the file is instead. Attribute order is not assumed. + * + * @param {string|null|undefined} html + * @param {string} pageUrl + * @returns {string|null} + */ +export function descriptorLinkFrom(html, pageUrl) { + if (typeof html !== 'string') return null; + const tags = html.match(/]*>/gi) ?? []; + for (const tag of tags) { + const rel = /\brel\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.toLowerCase() ?? ''; + if (!/\bopenwebring\b/.test(rel)) continue; + const href = /\bhref\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.trim(); + if (!href) continue; + try { + const u = new URL(href, pageUrl); + if (/^https?:$/.test(u.protocol)) return u.toString(); + } catch { + // A href that is not a URL is no descriptor. + } + } + return null; +} + export function descriptorUrlFor(siteUrl) { try { const u = new URL(siteUrl); @@ -223,12 +252,23 @@ export async function checkRingMember({ base, ringSlug, memberUrl, fetchText }) const html = await fetchText(memberUrl, { accept: 'text/html' }).catch(() => null); const linkedFromPage = linksToRing(html, base, ringSlug); - const descriptorUrl = descriptorUrlFor(memberUrl); + // The well-known file on the origin first; when there is none, the file + // the page itself points at. Both are the member's own words; a page on a + // shared host has only the second. + let descriptorUrl = descriptorUrlFor(memberUrl); let descriptor = null; if (descriptorUrl) { const text = await fetchText(descriptorUrl, { accept: 'application/json' }).catch(() => null); descriptor = parseRingDescriptor(text); } + if (!descriptor) { + const linked = descriptorLinkFrom(html, memberUrl); + if (linked && linked !== descriptorUrl) { + const text = await fetchText(linked, { accept: 'application/json' }).catch(() => null); + descriptor = parseRingDescriptor(text); + if (descriptor) descriptorUrl = linked; + } + } const namedInDescriptor = descriptorNamesRing(descriptor, base, ringSlug); return {