UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

457 lines (451 loc) • 16.7 kB
import { Temporal } from "@js-temporal/polyfill"; import { URLPattern } from "urlpattern-polyfill"; import { getUserAgent } from "./docloader-b8Rvguvo.js"; import { getLogger } from "@logtape/logtape"; //#region nodeinfo/semver.ts const MAX_LENGTH = 256; /** * A single `0`, or a non-zero digit followed by zero or more digits. */ const NUMERIC_IDENTIFIER = "0|[1-9]\\d*"; /** * Zero or more digits, followed by a letter or hyphen, and then zero or more letters, digits, or hyphens. */ const NON_NUMERIC_IDENTIFIER = "\\d*[a-zA-Z-][a-zA-Z0-9-]*"; /** * Three dot-separated numeric identifiers. */ const VERSION_CORE = `(?<major>${NUMERIC_IDENTIFIER})\\.(?<minor>${NUMERIC_IDENTIFIER})\\.(?<patch>${NUMERIC_IDENTIFIER})`; /** * A numeric identifier, or a non-numeric identifier. */ const PRERELEASE_IDENTIFIER = `(?:${NUMERIC_IDENTIFIER}|${NON_NUMERIC_IDENTIFIER})`; /** * A hyphen, followed by one or more dot-separated pre-release version identifiers. * @example "-pre.release" */ const PRERELEASE = `(?:-(?<prerelease>${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))`; /** * Any combination of digits, letters, or hyphens. */ const BUILD_IDENTIFIER = "[0-9A-Za-z-]+"; /** * A plus sign, followed by one or more period-separated build metadata identifiers. * @example "+build.meta" */ const BUILD = `(?:\\+(?<buildmetadata>${BUILD_IDENTIFIER}(?:\\.${BUILD_IDENTIFIER})*))`; /** * A version, followed optionally by a pre-release version and build metadata. */ const FULL_VERSION = `v?${VERSION_CORE}${PRERELEASE}?${BUILD}?`; const FULL_REGEXP = /* @__PURE__ */ new RegExp(`^${FULL_VERSION}$`); /** * Attempt to parse a string as a semantic version, returning a SemVer object. * * @example Usage * ```ts * import { parseSemVer } from "@fedify/fedify/nodeinfo"; * import { assertEquals } from "@std/assert"; * * const version = parseSemVer("1.2.3"); * assertEquals(version, { * major: 1, * minor: 2, * patch: 3, * prerelease: [], * build: [], * }); * ``` * * @throws {TypeError} If the input string is invalid. * @param value The version string to parse * @returns A valid SemVer * @since 1.2.0 */ function parseSemVer(value) { if (typeof value !== "string") throw new TypeError(`Cannot parse version as version must be a string: received ${typeof value}`); if (value.length > MAX_LENGTH) throw new TypeError(`Cannot parse version as version length is too long: length is ${value.length}, max length is ${MAX_LENGTH}`); value = value.trim(); const groups = value.match(FULL_REGEXP)?.groups; if (!groups) throw new TypeError(`Cannot parse version: ${value}`); const major = parseNumber(groups.major, `Cannot parse version ${value}: invalid major version`); const minor = parseNumber(groups.minor, `Cannot parse version ${value}: invalid minor version`); const patch = parseNumber(groups.patch, `Cannot parse version ${value}: invalid patch version`); const prerelease = groups.prerelease ? parsePrerelease(groups.prerelease) : []; const build = groups.buildmetadata ? parseBuild(groups.buildmetadata) : []; return { major, minor, patch, prerelease, build }; } /** * Returns true if the value is a valid SemVer number. * * Must be a number. Must not be NaN. Can be positive or negative infinity. * Can be between 0 and MAX_SAFE_INTEGER. * @param value The value to check * @returns True if its a valid semver number */ function isValidNumber(value) { return typeof value === "number" && !Number.isNaN(value) && (!Number.isFinite(value) || 0 <= value && value <= Number.MAX_SAFE_INTEGER); } const NUMERIC_IDENTIFIER_REGEXP = /* @__PURE__ */ new RegExp(`^${NUMERIC_IDENTIFIER}$`); function parsePrerelease(prerelease) { return prerelease.split(".").filter(Boolean).map((id) => { if (NUMERIC_IDENTIFIER_REGEXP.test(id)) { const number = Number(id); if (isValidNumber(number)) return number; } return id; }); } function parseBuild(buildmetadata) { return buildmetadata.split(".").filter(Boolean); } function parseNumber(input, errorMessage) { const number = Number(input); if (!isValidNumber(number)) throw new TypeError(errorMessage); return number; } function formatNumber(value) { return value.toFixed(0); } /** * Format a SemVer object into a string. * * @example Usage * ```ts * import { formatSemVer } from "@fedify/fedify/nodeinfo"; * import { assertEquals } from "@std/assert"; * * const semver = { * major: 1, * minor: 2, * patch: 3, * }; * assertEquals(formatSemVer(semver), "1.2.3"); * ``` * * @param version The SemVer to format * @returns The string representation of a semantic version. * @since 1.2.0 */ function formatSemVer(version) { const major = formatNumber(version.major); const minor = formatNumber(version.minor); const patch = formatNumber(version.patch); const pre = version.prerelease?.join(".") ?? ""; const build = version.build?.join(".") ?? ""; const primary = `${major}.${minor}.${patch}`; const release = [primary, pre].filter((v) => v).join("-"); return [release, build].filter((v) => v).join("+"); } //#endregion //#region nodeinfo/client.ts const logger = getLogger([ "fedify", "nodeinfo", "client" ]); async function getNodeInfo(url, options = {}) { try { let nodeInfoUrl = 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 void 0; } const wellKnownRd = await wellKnownResponse.json(); const link = wellKnownRd?.links?.find((link$1) => link$1 != null && "rel" in link$1 && (link$1.rel === "http://nodeinfo.diaspora.software/ns/schema/2.0" || link$1.rel === "http://nodeinfo.diaspora.software/ns/schema/2.1") && "href" in link$1 && link$1.href != null); if (link == null) { logger.error("Failed to find a NodeInfo document link from {url}: {resourceDescriptor}", { url: wellKnownUrl.href, resourceDescriptor: wellKnownRd }); return void 0; } 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 void 0; } const data = await response.json(); if (options.parse === "none") return data; return parseNodeInfo(data, { tryBestEffort: options.parse === "best-effort" }) ?? void 0; } catch (error) { logger.error("Failed to fetch NodeInfo document from {url}: {error}", { url: url.toString(), error }); return void 0; } } /** * 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 */ function parseNodeInfo(data, options = {}) { if (typeof data !== "object" || data == null || !("software" in data)) return null; const software = parseSoftware(data.software, options); if (software == null) return null; let protocols = []; if ("protocols" in data && Array.isArray(data.protocols)) { const ps = data.protocols.map(parseProtocol); protocols = ps.filter((p) => p != null); if (ps.length != protocols.length && !options.tryBestEffort) return null; } else if (!options.tryBestEffort) return null; let services; 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; if ("openRegistrations" in data) { if (typeof data.openRegistrations === "boolean") openRegistrations = data.openRegistrations; else if (!options.tryBestEffort) return null; } let 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; 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 = { software, protocols, usage }; if (services != null) result.services = services; if (openRegistrations != null) result.openRegistrations = openRegistrations; if (metadata != null) result.metadata = metadata; return result; } function parseSoftware(data, options = {}) { if (typeof data !== "object" || data == null) { if (!options.tryBestEffort) data = {}; return null; } let name; 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; if ("version" in data && typeof data.version === "string") try { version = parseSemVer(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; 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; 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 = { name, version }; if (repository != null) result.repository = repository; if (homepage != null) result.homepage = homepage; return result; } function parseProtocol(data) { if (data === "activitypub" || data === "buddycloud" || data === "dfrn" || data === "diaspora" || data === "libertree" || data === "ostatus" || data === "pumpio" || data === "tent" || data === "xmpp" || data === "zot") return data; return null; } function parseServices(data, options = {}) { if (!(typeof data === "object") || data == null) { if (options.tryBestEffort) return {}; return null; } let inbound; if ("inbound" in data && Array.isArray(data.inbound)) { const is = data.inbound.map(parseInboundService); inbound = is.filter((i) => i != null); if (is.length > inbound.length && !options.tryBestEffort) return null; } let outbound; if ("outbound" in data && Array.isArray(data.outbound)) { const os = data.outbound.map(parseOutboundService); outbound = os.filter((o) => o != null); if (os.length > outbound.length && !options.tryBestEffort) return null; } const result = {}; if (inbound != null) result.inbound = inbound; if (outbound != null) result.outbound = outbound; return result; } function parseInboundService(data) { if (data === "atom1.0" || data === "gnusocial" || data === "imap" || data === "pnut" || data === "pop3" || data === "pumpio" || data === "rss2.0" || data === "twitter") return data; return null; } function parseOutboundService(data) { 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") return data; return null; } function parseUsage(data, options = {}) { if (typeof data !== "object" || data == null) return null; const 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 }; } //#endregion //#region nodeinfo/types.ts /** * Converts a {@link NodeInfo} object to a JSON value. * @param nodeInfo The {@link NodeInfo} object to convert. * @returns The JSON value that complies with the NodeInfo schema. * @throws {TypeError} If the {@link NodeInfo} object is invalid. */ function nodeInfoToJson(nodeInfo) { if (!nodeInfo.software.name.match(/^[a-z0-9-]+$/)) throw new TypeError("Invalid software name."); if (nodeInfo.protocols.length < 1) throw new TypeError("At least one protocol must be supported."); if (nodeInfo.usage.users.total != null && (nodeInfo.usage.users.total < 0 || !Number.isInteger(nodeInfo.usage.users.total))) throw new TypeError("Invalid total users."); if (nodeInfo.usage.users.activeHalfyear != null && (nodeInfo.usage.users.activeHalfyear < 0 || !Number.isInteger(nodeInfo.usage.users.activeHalfyear))) throw new TypeError("Invalid active halfyear users."); if (nodeInfo.usage.users.activeMonth != null && (nodeInfo.usage.users.activeMonth < 0 || !Number.isInteger(nodeInfo.usage.users.activeMonth))) throw new TypeError("Invalid active month users."); if (nodeInfo.usage.localPosts < 0 || !Number.isInteger(nodeInfo.usage.localPosts)) throw new TypeError("Invalid local posts."); if (nodeInfo.usage.localComments < 0 || !Number.isInteger(nodeInfo.usage.localComments)) throw new TypeError("Invalid local comments."); return { "$schema": "http://nodeinfo.diaspora.software/ns/schema/2.1#", version: "2.1", software: { name: nodeInfo.software.name, version: formatSemVer(nodeInfo.software.version), repository: nodeInfo.software.repository?.href, homepage: nodeInfo.software.homepage?.href }, protocols: nodeInfo.protocols, services: nodeInfo.services == null ? { inbound: [], outbound: [] } : { inbound: nodeInfo.services.inbound ?? [], outbound: nodeInfo.services.outbound ?? [] }, openRegistrations: nodeInfo.openRegistrations ?? false, usage: { users: nodeInfo.usage.users, localPosts: nodeInfo.usage.localPosts, localComments: nodeInfo.usage.localComments }, metadata: nodeInfo.metadata ?? {} }; } //#endregion export { formatSemVer, getNodeInfo, nodeInfoToJson, parseNodeInfo, parseSemVer };