ditto/packages/utils/favicon.ts
2025-02-17 15:32:18 -06:00

116 lines
3.4 KiB
TypeScript

import { DOMParser } from '@b-fuze/deno-dom';
import { DittoConf } from '@ditto/conf';
import { DittoTables } from '@ditto/db';
import { cachedFaviconsSizeGauge } from '@ditto/metrics';
import { logi } from '@soapbox/logi';
import { safeFetch } from '@soapbox/safe-fetch';
import { Kysely } from 'kysely';
import tldts from 'tldts';
import { nostrNow } from '@/utils.ts';
import { SimpleLRU } from './SimpleLRU.ts';
let faviconCache: SimpleLRU<string, URL> | undefined;
interface ResolveFaviconOpts {
conf: DittoConf;
kysely: Kysely<DittoTables>;
signal?: AbortSignal;
}
export function resolveFavicon(opts: ResolveFaviconOpts, domain: string): Promise<URL> {
const { conf, kysely } = opts;
if (!faviconCache) {
faviconCache = new SimpleLRU<string, URL>(
async (domain, { signal }) => {
const row = await queryFavicon(kysely, domain);
if (row && (nostrNow() - row.last_updated_at) < (conf.caches.favicon.ttl / 1000)) {
return new URL(row.favicon);
}
const url = await fetchFavicon(domain, signal);
await insertFavicon(kysely, domain, url.href);
return url;
},
{ ...conf.caches.favicon, gauge: cachedFaviconsSizeGauge },
);
}
return faviconCache.fetch(domain, opts);
}
async function queryFavicon(
kysely: Kysely<DittoTables>,
domain: string,
): Promise<DittoTables['domain_favicons'] | undefined> {
return await kysely
.selectFrom('domain_favicons')
.selectAll()
.where('domain', '=', domain)
.limit(1)
.executeTakeFirst();
}
async function insertFavicon(kysely: Kysely<DittoTables>, domain: string, favicon: string): Promise<void> {
await kysely
.insertInto('domain_favicons')
.values({ domain, favicon, last_updated_at: nostrNow() })
.onConflict((oc) => oc.column('domain').doUpdateSet({ favicon, last_updated_at: nostrNow() }))
.execute();
}
async function fetchFavicon(domain: string, signal?: AbortSignal): Promise<URL> {
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'started' });
const tld = tldts.parse(domain);
if (!tld.isIcann || tld.isIp || tld.isPrivate) {
throw new Error(`Invalid favicon domain: ${domain}`);
}
const rootUrl = new URL('/', `https://${domain}/`);
const response = await safeFetch(rootUrl, { signal });
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const link = doc.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
if (link) {
const href = link.getAttribute('href');
if (href) {
let url: URL | undefined;
try {
url = new URL(href);
} catch {
try {
url = new URL(href, rootUrl);
} catch {
// fall through
}
}
if (url) {
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'found', url });
return url;
}
}
}
// Fallback to checking `/favicon.ico` of the domain.
const url = new URL('/favicon.ico', `https://${domain}/`);
const fallback = await safeFetch(url, { method: 'HEAD', signal });
const contentType = fallback.headers.get('content-type');
if (fallback.ok && ['image/vnd.microsoft.icon', 'image/x-icon'].includes(contentType!)) {
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'found', url });
return url;
}
logi({ level: 'info', ns: 'ditto.favicon', domain, state: 'failed' });
throw new Error(`Favicon not found: ${domain}`);
}