UNPKG

social-butterfly

Version:

Incorporate federated social network protocols easily. Used with Hello, world federated blog.

187 lines (157 loc) 7.5 kB
import { buildUrl, ensureAbsoluteUrl } from './util/url_factory'; import { createAbsoluteUrl, fetchJSON, fetchText } from './util/crawler'; import cheerio from 'cheerio'; import { discoverAndParseFeedFromUrl } from './util/feeds'; // Get the /.well-known/host-meta resource. // In other words, get the link to the WebFinger resource. // We prefer JSON, or XML if JSON is not found. export async function getLRDD(url) { const parsedUrl = new URL(url); const hostMetaUrl = `${parsedUrl.protocol}//${parsedUrl.host}/.well-known/host-meta`; let lrddUrl, $; try { const hostMetaXML = await fetchText(hostMetaUrl); $ = cheerio.load(hostMetaXML); lrddUrl = $('link[rel="lrdd"][type="application/json"]').attr('template'); if (!lrddUrl) { lrddUrl = $('link[rel="lrdd"]').attr('template'); } } catch (ex) {} return lrddUrl; } // Has all the links to different feeds/data about the user. See webfinger.js for example. export async function getWebfinger(lrddUrl, uri) { const webfingerUrl = lrddUrl.replace('{uri}', encodeURIComponent(uri)); let webfingerDoc, webfingerInfo; try { webfingerDoc = await fetchText(webfingerUrl); } catch (ex) { try { // Fallback to probing for username@hostname if possible. Some sites like socialhome require this. const parsedUrl = new URL(uri); const acct = `${parsedUrl.pathname .split('/') .filter((p) => !!p) .slice(-1)}@${parsedUrl.hostname}`; const acctWebfingerUrl = lrddUrl.replace('{uri}', encodeURIComponent(acct)); webfingerDoc = await fetchText(acctWebfingerUrl); } catch (ex) { return null; } } let success = false; try { const json = JSON.parse(webfingerDoc); const linkMap = {}; json.links.map((link) => (linkMap[link.rel] = link)); const activityPubActorUrl = json.links.find( (link) => link.rel === 'self' && link.type === 'application/activity+json' ); webfingerInfo = { feed_url: linkMap['http://schemas.google.com/g/2010#updates-from']?.href, salmon_url: linkMap['salmon']?.href, activitypub_actor_url: activityPubActorUrl?.href, webmention_url: linkMap['webmention']?.href, magic_key: (linkMap['magic-public-key']?.href || '').replace('data:application/magic-public-key,', ''), profile_url: json.aliases.find((alias) => alias.startsWith('https:') || alias.startsWith('http:')), }; success = true; } catch (ex) { // Fall-through, and try XML parsing. } if (!success) { try { const $ = cheerio.load(webfingerDoc); webfingerInfo = { feed_url: $('link[rel="http://schemas.google.com/g/2010#updates-from"]').attr('href'), salmon_url: $('link[rel="salmon"]').attr('href'), activitypub_actor_url: $('link[rel="self"][type="application/activity+json"]').attr('href'), webmention_url: $('link[rel="webmention"]').attr('href'), magic_key: $('link[rel="magic-public-key"]').attr('href').replace('data:application/magic-public-key,', ''), profile_url: $('alias').first().text().startsWith('https:') || $('alias').first().text().startsWith('http:') || $('alias').last().text(), }; } catch (ex) { return null; } } if (webfingerInfo.activitypub_actor_url) { try { const actorJSON = await getActivityPubActor(webfingerInfo.activitypub_actor_url); // TODO(mime): not the cleanest naming, we're overwriting the magic_key and preferring the PEM from // the actor JSON. should rename this field to reflect that it is has magic or PEM format for public key. webfingerInfo.magic_key = actorJSON['publicKey']['publicKeyPem']; webfingerInfo.activitypub_inbox_url = actorJSON['inbox']; } catch (ex) { // Ignore, if we can't get the actor info. } } return webfingerInfo; } export async function getActivityPubActor(url) { return await fetchJSON(url, { Accept: 'application/activity+json', }); } export async function getHTML(url) { let $; try { const html = await fetchText(url); $ = cheerio.load(html); } catch (ex) { return null; } return $; } export async function discoverUserRemoteInfoSaveAndSubscribe(req, options, url, local_username) { const userRemote = await getUserRemoteInfo(url, local_username); const existingUserRemote = await options.getRemoteUser(userRemote.local_username, userRemote.profile_url); userRemote.id = existingUserRemote?.id || undefined; userRemote.following = true; await options.saveRemoteUser(userRemote); if (req && userRemote.hub_url) { const userRemoteParams = { localUsername: userRemote.local_username, remoteProfileUrl: userRemote.profile_url }; const callbackUrl = buildUrl({ req, pathname: '/websub', searchParams: userRemoteParams }); await options.webSubSubscriberServer.subscribe(userRemote.hub_url, options.constants.webSubHub, callbackUrl); } return await options.getRemoteUser(userRemote.local_username, userRemote.profile_url); } export async function getUserRemoteInfo(websiteUrl, local_username) { let userRemote = { local_username }; const lrddUrl = await getLRDD(websiteUrl); if (lrddUrl) { const webfingerInfo = await getWebfinger(lrddUrl, websiteUrl); userRemote = Object.assign({}, userRemote, webfingerInfo); } userRemote.feed_url = ensureAbsoluteUrl(websiteUrl, userRemote.feed_url); const { feedMeta, feedUrl } = await discoverAndParseFeedFromUrl(userRemote.feed_url || websiteUrl); userRemote.feed_url = feedUrl; const htmlDoc = await getHTML(websiteUrl); const atomLinks = feedMeta['atom:link'] ? [feedMeta['atom:link']].flat(1) : []; userRemote.profile_url = userRemote.profile_url || feedMeta['atom:author']?.['uri']?.['#'] || websiteUrl; userRemote.hub_url = atomLinks.find((el) => el['@'].rel === 'hub')?.['@'].href; userRemote.salmon_url = userRemote.salmon_url || atomLinks.find((el) => el['@'].rel === 'salmon')?.['@'].href; userRemote.webmention_url = userRemote.webmention_url || htmlDoc('link[rel="webmention"]').attr('href'); userRemote.username = userRemote.username || feedMeta['atom:author']?.['poco:preferredusername']?.['#'] || feedMeta.title; userRemote.name = feedMeta['atom:author']?.['poco:displayname']?.['#'] || ''; userRemote.favicon = feedMeta.favicon || createAbsoluteUrl(websiteUrl, htmlDoc('link[rel="shortcut icon"]')['href']) || createAbsoluteUrl(websiteUrl, htmlDoc('link[rel="icon"]')['href']) || createAbsoluteUrl(websiteUrl, '/favicon.ico'); userRemote.avatar = feedMeta.image?.url || userRemote.favicon; userRemote.order = Math.pow(2, 31) - 1; // If activitypub_actor_url not found, fallback to profile_url. Used in Salmon lookups. userRemote.activitypub_actor_url = userRemote.activitypub_actor_url || userRemote.profile_url; userRemote.salmon_url = ensureAbsoluteUrl(websiteUrl, userRemote.salmon_url); userRemote.activitypub_actor_url = ensureAbsoluteUrl(websiteUrl, userRemote.activitypub_actor_url); userRemote.activitypub_inbox_url = ensureAbsoluteUrl(websiteUrl, userRemote.activitypub_inbox_url); userRemote.webmention_url = ensureAbsoluteUrl(websiteUrl, userRemote.webmention_url); userRemote.profile_url = ensureAbsoluteUrl(websiteUrl, userRemote.profile_url); userRemote.feed_url = ensureAbsoluteUrl(websiteUrl, userRemote.feed_url); userRemote.hub_url = ensureAbsoluteUrl(websiteUrl, userRemote.hub_url); return userRemote; }