This release is 3 versions behind 1.3.3 — the latest version of @fedify/fedify. Jump to latest
Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
An ActivityPub/fediverse server framework
This package works with Node.js, Deno, Bun
JSR Score
100%
Published
2 months ago (1.3.0)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451import { getLogger } from "jsr:@logtape/logtape@^0.8.0"; import { parse, type SemVer } from "jsr:@std/semver@^1.0.3"; import { getUserAgent, type GetUserAgentOptions, } from "../runtime/docloader.ts"; import type { ResourceDescriptor } from "../webfinger/jrd.ts"; import type { InboundService, JsonValue, NodeInfo, OutboundService, Protocol, Services, Software, Usage, } from "./types.ts"; const logger = getLogger(["fedify", "nodeinfo", "client"]); /** * Options for {@link getNodeInfo} function. * @since 1.2.0 */ export interface GetNodeInfoOptions { /** * Whether to directly fetch the NodeInfo document from the given URL. * Otherwise, the NodeInfo document will be fetched from the `.well-known` * location of the given URL. * * Turned off by default. */ direct?: boolean; /** * How strictly to parse the NodeInfo document. * * - `"strict"`: Parse the NodeInfo document strictly. If the document is * invalid, `undefined` is returned. This is the default. * - `"best-effort"`: Try to parse the NodeInfo document even if it is * invalid. * - `"none"`: Do not parse the NodeInfo document. The function will return * the raw JSON value. */ parse?: "strict" | "best-effort" | "none"; /** * The options for making `User-Agent` header. * If a string is given, it is used as the `User-Agent` header value. * If an object is given, it is passed to {@link getUserAgent} to generate * the `User-Agent` header value. * @since 1.3.0 */ userAgent?: GetUserAgentOptions | string; } /** * Fetches a NodeInfo document from the given URL. * @param url The base URL of the server. If `options.direct` is turned off * (default), the NodeInfo document will be fetched from * the `.well-known` location of this URL (hence the only origin * of the URL is used). If `options.direct` is turned on, * the NodeInfo document will be fetched from the given URL. * @param options Options for fetching the NodeInfo document. * @returns The NodeInfo document if it could be fetched successfully. * Otherwise, `undefined` is returned. * @since 1.2.0 */ export async function getNodeInfo( url: URL | string, options?: GetNodeInfoOptions & { parse?: "strict" | "best-effort" }, ): Promise<NodeInfo | undefined>; /** * Fetches a NodeInfo document from the given URL. * @param url The base URL of the server. If `options.direct` is turned off * (default), the NodeInfo document will be fetched from * the `.well-known` location of this URL (hence the only origin * of the URL is used). If `options.direct` is turned on, * the NodeInfo document will be fetched from the given URL. * @param options Options for fetching the NodeInfo document. * @returns The NodeInfo document if it could be fetched successfully. * Otherwise, `undefined` is returned. * @since 1.2.0 */ export async function getNodeInfo( url: URL | string, options: GetNodeInfoOptions & { parse: "none" }, ): Promise<JsonValue | undefined>; export async function getNodeInfo( url: URL | string, options: GetNodeInfoOptions = {}, ): Promise<NodeInfo | JsonValue | undefined> { try { let nodeInfoUrl: URL | string = url; if (!options.direct) { const wellKnownUrl = new URL("/.well-known/nodeinfo", url); const wellKnownResponse = await fetch(wellKnownUrl, { headers: { Accept: "application/json", "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent), }, }); if (!wellKnownResponse.ok) { logger.error("Failed to fetch {url}: {status} {statusText}", { url: wellKnownUrl.href, status: wellKnownResponse.status, statusText: wellKnownResponse.statusText, }); return undefined; } const wellKnownRd = await wellKnownResponse.json() as ResourceDescriptor; const link = wellKnownRd?.links?.find((link) => link != null && "rel" in link && (link.rel === "http://nodeinfo.diaspora.software/ns/schema/2.0" || link.rel === "http://nodeinfo.diaspora.software/ns/schema/2.1") && "href" in link && link.href != null ); if (link == null) { logger.error( "Failed to find a NodeInfo document link from {url}: {resourceDescriptor}", { url: wellKnownUrl.href, resourceDescriptor: wellKnownRd }, ); return undefined; } nodeInfoUrl = link.href; } const response = await fetch(nodeInfoUrl, { headers: { Accept: "application/json", "User-Agent": typeof options.userAgent === "string" ? options.userAgent : getUserAgent(options.userAgent), }, }); if (!response.ok) { logger.error( "Failed to fetch NodeInfo document from {url}: {status} {statusText}", { url: nodeInfoUrl.toString(), status: response.status, statusText: response.statusText, }, ); return undefined; } const data = await response.json(); if (options.parse === "none") return data as JsonValue; return parseNodeInfo(data, { tryBestEffort: options.parse === "best-effort", }) ?? undefined; } catch (error) { logger.error("Failed to fetch NodeInfo document from {url}: {error}", { url: url.toString(), error, }); return undefined; } } /** * Options for {@link parseNodeInfo} function. * @since 1.2.0 */ export interface ParseNodeInfoOptions { /** * Whether to try to parse the NodeInfo document even if it is invalid. * If turned on, the function will return a best-effort result. * * Turned off by default. */ tryBestEffort?: boolean; } /** * Parses a NodeInfo document. * @param data A JSON value that complies with the NodeInfo schema. * @param options Options for parsing the NodeInfo document. * @returns The parsed NodeInfo document if it is valid. Otherwise, `null` * is returned. * @since 1.2.0 */ export function parseNodeInfo( data: unknown, options: ParseNodeInfoOptions = {}, ): NodeInfo | null { if (typeof data !== "object" || data == null || !("software" in data)) { return null; } const software = parseSoftware(data.software, options); if (software == null) return null; let protocols: Protocol[] = []; if ("protocols" in data && Array.isArray(data.protocols)) { const ps = data.protocols.map(parseProtocol); protocols = ps.filter((p) => p != null) as Protocol[]; if (ps.length != protocols.length && !options.tryBestEffort) return null; } else { if (!options.tryBestEffort) return null; } let services: Services | undefined; if ("services" in data) { if (typeof data.services === "object" && data.services != null) { const ss = parseServices(data.services, options); if (ss == null) { if (!options.tryBestEffort) return null; } else { services = ss; } } else if (!options.tryBestEffort) return null; } let openRegistrations: boolean | undefined; if ("openRegistrations" in data) { if (typeof data.openRegistrations === "boolean") { openRegistrations = data.openRegistrations; } else { if (!options.tryBestEffort) return null; } } let usage: Usage = { users: {}, localPosts: 0, localComments: 0, }; if ("usage" in data) { const u = parseUsage(data.usage, options); if (u == null) { if (!options.tryBestEffort) return null; } else { usage = u; } } let metadata: Record<string, JsonValue> | undefined; if ("metadata" in data) { if (typeof data.metadata === "object" && data.metadata != null) { metadata = Object.fromEntries(Object.entries(data.metadata)); } else if (!options.tryBestEffort) return null; } const result: NodeInfo = { software, protocols, usage }; if (services != null) result.services = services; if (openRegistrations != null) result.openRegistrations = openRegistrations; if (metadata != null) result.metadata = metadata; return result; } export function parseSoftware( data: unknown, options: ParseNodeInfoOptions = {}, ): Software | null { if (typeof data !== "object" || data == null) { if (!options.tryBestEffort) data = {}; return null; } let name: string; if ( "name" in data && typeof data.name === "string" && data.name.match(/^\s*[A-Za-z0-9-]+\s*$/) ) { if (!data.name.match(/^[a-z0-9-]+$/) && !options.tryBestEffort) return null; name = data.name.trim().toLowerCase(); } else { return null; } let version: SemVer; if ("version" in data && typeof data.version === "string") { try { version = parse(data.version); } catch { if (!options.tryBestEffort) return null; version = { major: 0, minor: 0, patch: 0, build: [], prerelease: [] }; } } else { if (!options.tryBestEffort) return null; version = { major: 0, minor: 0, patch: 0, build: [], prerelease: [] }; } let repository: URL | undefined; if ("repository" in data) { if (typeof data.repository === "string") { try { repository = new URL(data.repository); } catch { if (!options.tryBestEffort) return null; } } else { if (!options.tryBestEffort) return null; } } let homepage: URL | undefined; if ("homepage" in data) { if (typeof data.homepage === "string") { try { homepage = new URL(data.homepage); } catch { if (!options.tryBestEffort) return null; } } else { if (!options.tryBestEffort) return null; } } const result: Software = { name, version }; if (repository != null) result.repository = repository; if (homepage != null) result.homepage = homepage; return result; } export function parseProtocol(data: unknown): Protocol | null { // cSpell: disable if ( data === "activitypub" || data === "buddycloud" || data === "dfrn" || data === "diaspora" || data === "libertree" || data === "ostatus" || data === "pumpio" || data === "tent" || data === "xmpp" || data === "zot" ) { // cSpell: enable return data; } return null; } export function parseServices( data: unknown, options: ParseNodeInfoOptions = {}, ): Services | null { if (!(typeof data === "object") || data == null) { if (options.tryBestEffort) return {}; return null; } let inbound: InboundService[] | undefined; if ("inbound" in data && Array.isArray(data.inbound)) { const is = data.inbound.map(parseInboundService); inbound = is.filter((i) => i != null) as InboundService[]; if (is.length > inbound.length && !options.tryBestEffort) return null; } let outbound: OutboundService[] | undefined; if ("outbound" in data && Array.isArray(data.outbound)) { const os = data.outbound.map(parseOutboundService); outbound = os.filter((o) => o != null) as OutboundService[]; if (os.length > outbound.length && !options.tryBestEffort) return null; } const result: Services = {}; if (inbound != null) result.inbound = inbound; if (outbound != null) result.outbound = outbound; return result; } export function parseInboundService(data: unknown): InboundService | null { // cSpell: disable if ( data === "atom1.0" || data === "gnusocial" || data === "imap" || data === "pnut" || data === "pop3" || data === "pumpio" || data === "rss2.0" || data === "twitter" ) { // cSpell: enable return data; } return null; } export function parseOutboundService(data: unknown): OutboundService | null { // cSpell: disable if ( data === "atom1.0" || data === "blogger" || data === "buddycloud" || data === "diaspora" || data === "dreamwidth" || data === "drupal" || data === "facebook" || data === "friendica" || data === "gnusocial" || data === "google" || data === "insanejournal" || data === "libertree" || data === "linkedin" || data === "livejournal" || data === "mediagoblin" || data === "myspace" || data === "pinterest" || data === "pnut" || data === "posterous" || data === "pumpio" || data === "redmatrix" || data === "rss2.0" || data === "smtp" || data === "tent" || data === "tumblr" || data === "twitter" || data === "wordpress" || data === "xmpp" ) { // cSpell: enable return data; } return null; } export function parseUsage( data: unknown, options: ParseNodeInfoOptions = {}, ): Usage | null { if (typeof data !== "object" || data == null) return null; const users: Usage["users"] = {}; if ("users" in data && typeof data.users === "object" && data.users != null) { if ("total" in data.users) { if (typeof data.users.total === "number") { users.total = data.users.total; } else { if (!options.tryBestEffort) return null; if (typeof data.users.total === "string") { const n = parseInt(data.users.total); if (!isNaN(n)) users.total = n; } } } if ("activeHalfyear" in data.users) { if (typeof data.users.activeHalfyear === "number") { users.activeHalfyear = data.users.activeHalfyear; } else { if (!options.tryBestEffort) return null; if (typeof data.users.activeHalfyear === "string") { const n = parseInt(data.users.activeHalfyear); if (!isNaN(n)) users.activeHalfyear = n; } } } if ("activeMonth" in data.users) { if (typeof data.users.activeMonth === "number") { users.activeMonth = data.users.activeMonth; } else { if (!options.tryBestEffort) return null; if (typeof data.users.activeMonth === "string") { const n = parseInt(data.users.activeMonth); if (!isNaN(n)) users.activeMonth = n; } } } } else { if (!options.tryBestEffort) return null; } let localPosts = 0; if ("localPosts" in data) { if (typeof data.localPosts === "number") { localPosts = data.localPosts; } else { if (!options.tryBestEffort) return null; if (typeof data.localPosts === "string") { const n = parseInt(data.localPosts); if (!isNaN(n)) localPosts = n; } } } let localComments = 0; if ("localComments" in data) { if (typeof data.localComments === "number") { localComments = data.localComments; } else { if (!options.tryBestEffort) return null; if (typeof data.localComments === "string") { const n = parseInt(data.localComments); if (!isNaN(n)) localComments = n; } } } return { users, localPosts, localComments }; }