Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/test/openwebring.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<link rel="alternate openwebring" href="openwebring.json"><a href="${BASE}/ring/physics/next?from=${site}">next</a>`;
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 ? '<p>plain</p>' : 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 = `<a href="${BASE}/ring/physics/next?from=https://alpha.example/">next</a>`;
const descriptor = JSON.stringify({ made_by: 'both', disclosure: 'ai-assisted', rings: [] });
Expand Down
42 changes: 41 additions & 1 deletion packages/ingest/src/webring.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ export function linksToRing(html, base, slug) {
* @param {string} siteUrl
* @returns {string|null}
*/
/**
* The descriptor a page points at with <link rel="openwebring" href="...">,
* 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(/<link\b[^>]*>/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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading