import { getNodeInfo } from "@fedify/fedify"; import { getUserAgent, validatePublicUrl } from "@fedify/vocab-runtime"; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; import { getLogger } from "@logtape/logtape"; import { bindConfig } from "@optique/config"; import { argument, command as Command, constant, flag, group, type InferValue, merge, message, object, string, text, } from "@optique/core"; import { print, printError } from "@optique/run"; import type { ChalkInstance } from "chalk"; import { isICO, parseICO } from "icojs"; import { defaultFormats, defaultPlugins, intToRGBA } from "jimp"; import { Buffer } from "node:buffer"; import { readFile } from "node:fs/promises"; import os from "node:os"; import process from "node:process"; import ora from "ora"; // @deno-types="npm:@types/pngjs@^6.0.5" import { PNG } from "pngjs"; import { configContext } from "./config.ts"; import { userAgentOption } from "./options.ts"; import { colors, formatObject } from "./utils.ts"; const logger = getLogger(["fedify", "cli", "nodeinfo"]); const MAX_FAVICON_REDIRECTS = 5; const SVG_SNIFF_LENGTH = 1024; async function fetchFaviconResource( url: URL | string, userAgent?: string, ): Promise<{ response: Response; url: URL }> { let current = typeof url === "string" ? new URL(url) : url; for (let redirects = 0;; redirects++) { await validatePublicUrl(current.href); const response = await fetch(current, { headers: { "User-Agent": userAgent == null ? getUserAgent() : userAgent, }, redirect: "manual", }); if ( response.status !== 301 && response.status !== 302 && response.status !== 303 && response.status !== 307 && response.status !== 308 ) { return { response, url: current }; } const location = response.headers.get("Location"); if (location == null) return { response, url: current }; await response.body?.cancel(); if (redirects >= MAX_FAVICON_REDIRECTS) { throw new Error("Too many favicon redirects"); } current = new URL(location, current); } } export const Jimp = createJimp({ formats: [...defaultFormats, webp], plugins: defaultPlugins, }); type JimpImage = Awaited>; type ImageBuffer = ArrayBuffer | Uint8Array; const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; type ResvgModule = typeof import("@resvg/resvg-wasm"); let svgRasterizer: Promise | undefined; function loadSvgRasterizer(): Promise { return svgRasterizer ??= (async () => { try { const resvg = await import("@resvg/resvg-wasm"); const wasmUrl = import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"); await resvg.initWasm(await readFile(new URL(wasmUrl))); return resvg; } catch (error) { logger.debug("Failed to load the SVG rasterizer: {error}", { error }); throw error; } })(); } export async function rasterizeSvg(buffer: ImageBuffer): Promise { const svg = toUint8Array(buffer); const { Resvg } = await loadSvgRasterizer(); const probe = new Resvg(svg); const { width, height } = probe; probe.free(); if (width < 1 || height < 1) { throw new RangeError(`Unusable SVG dimensions: ${width}x${height}`); } const resvg = new Resvg(svg, { fitTo: { mode: width >= height ? "width" : "height", value: DEFAULT_IMAGE_WIDTH, }, }); try { const rendered = resvg.render(); try { // Resvg WASM has no fonts, so text-only SVGs render fully transparent if (!hasVisiblePixel(rendered.pixels)) { throw new Error("The SVG favicon has no visible pixels."); } return rendered.asPng(); } finally { rendered.free(); } } finally { resvg.free(); } } function hasVisiblePixel(pixels: Uint8Array): boolean { for (let i = 3; i < pixels.length; i += 4) { if (pixels[i] !== 0) return true; } return false; } function isSvg(buffer: ImageBuffer): boolean { const text = new TextDecoder().decode( toUint8Array(buffer).subarray(0, SVG_SNIFF_LENGTH), ); return /]/i.test(text); } function toUint8Array(buffer: ImageBuffer): Uint8Array { return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); } function toNodeBuffer(buffer: ImageBuffer): Buffer { return Buffer.from(toUint8Array(buffer)); } function isPng(buffer: ImageBuffer): boolean { const bytes = toUint8Array(buffer); return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte); } async function decodePng( buffer: ImageBuffer, ): Promise<{ width: number; height: number; data: Buffer }> { return await new Promise((resolve, reject) => { new PNG().parse(toNodeBuffer(buffer), (error, png) => { if (error != null) { reject(error); } else { resolve(png); } }); }); } export async function readImage(buffer: ImageBuffer): Promise { if (!isPng(buffer)) return await Jimp.read(toNodeBuffer(buffer)); const png = await decodePng(buffer); return new Jimp({ width: png.width, height: png.height, data: png.data, }) as JimpImage; } const nodeInfoOption = merge( object("Display options", { raw: bindConfig( flag("-r", "--raw", { description: message`Show NodeInfo document in the raw JSON format`, }), { context: configContext, key: (config) => config.nodeinfo?.raw ?? false, default: false, }, ), noFavicon: bindConfig( flag("--no-favicon", { description: message`Disable fetching the favicon of the instance`, }), { context: configContext, key: (config) => config.nodeinfo?.showFavicon === false, default: false, }, ), metadata: bindConfig( flag("-m", "--metadata", { description: message`Show the extra metadata of the NodeInfo, i.e., the metadata field of the document.`, }), { context: configContext, key: (config) => config.nodeinfo?.showMetadata ?? false, default: false, }, ), }), object("Parsing options", { bestEffort: bindConfig( flag("-b", "--best-effort", { description: message`Parse the NodeInfo document with best effort. If the NodeInfo document is not well-formed, the option will try to parse it as much as possible.`, }), { context: configContext, key: (config) => config.nodeinfo?.bestEffort ?? false, default: false, }, ), }), ); export const nodeInfoOptions = merge( object("Arguments", { command: constant("nodeinfo"), host: argument(string({ metavar: "HOST" }), { description: message`Bare hostname or a full URL of the instance`, }), }), nodeInfoOption, group("Network options", userAgentOption), ); export const nodeInfoMetadata = { brief: message`Get information about a remote node using the NodeInfo protocol`, description: message`Get information about a remote node using the NodeInfo protocol. The argument is the hostname of the remote node, or the URL of the remote node.`, }; export const nodeInfoCommand = Command( "nodeinfo", nodeInfoOptions, nodeInfoMetadata, ); async function renderFavicon(url: URL, userAgent?: string): Promise { const { response } = await fetchFaviconResource(url, userAgent); if (!response.ok) { await response.body?.cancel(); throw new Error( `Failed to fetch the favicon: ${response.status} ${response.statusText}`, ); } const contentType = response.headers.get("Content-Type") ?.split(";")[0].trim().toLowerCase(); let buffer: ImageBuffer = await response.arrayBuffer(); if ( contentType === "image/vnd.microsoft.icon" || contentType === "image/x-icon" || isICO(buffer) ) { const images = await parseICO(buffer); if (images.length < 1) { throw new Error("No images found in the ICO file."); } buffer = images[0].buffer; } else if ( !isPng(buffer) && (contentType === "image/svg+xml" || isSvg(buffer)) ) { buffer = await rasterizeSvg(buffer); } const image = await readImage(buffer); const colorSupport = checkTerminalColorSupport(); return getAsciiArt(image, DEFAULT_IMAGE_WIDTH, colorSupport, colors) .split("\n").map((line) => ` ${line} `); } export async function runNodeInfo( command: InferValue, ) { const spinner = ora({ text: "Fetching a NodeInfo document...", discardStdin: false, }).start(); const url = new URL( URL.canParse(command.host) ? command.host : `https://${command.host}`, ); if (command.raw) { const nodeInfo = await getNodeInfo(url, { parse: "none", userAgent: command.userAgent, }); if (nodeInfo === undefined) { spinner.fail("No NodeInfo document found."); printError(message`No NodeInfo document found.`); process.exit(1); } spinner.succeed("NodeInfo document fetched."); print(message`${text(formatObject(nodeInfo, undefined, true))}`); return; } const nodeInfo = await getNodeInfo(url, { parse: command.bestEffort ? "best-effort" : "strict", userAgent: command.userAgent, }); logger.debug("NodeInfo document: {nodeInfo}", { nodeInfo }); if (nodeInfo == undefined) { spinner.fail("No NodeInfo document found or it is invalid."); printError(message`No NodeInfo document found or it is invalid.`); if (!command.bestEffort) { printError( message`Use the -b/--best-effort option to try to parse the document anyway.`, ); } process.exit(1); } let layout: string[]; let defaultWidth = 0; if (!command.noFavicon) { spinner.text = "Fetching the favicon..."; try { const faviconUrl = await getFaviconUrl(url, command.userAgent); const fallbackUrl = new URL("/favicon.ico", url); try { layout = await renderFavicon(faviconUrl, command.userAgent); } catch (error) { if (faviconUrl.href === fallbackUrl.href) throw error; logger.debug( "Failed to fetch or render {url}; retrying {fallbackUrl}: {error}", { url: faviconUrl.href, fallbackUrl: fallbackUrl.href, error }, ); layout = await renderFavicon(fallbackUrl, command.userAgent); } defaultWidth = 41; } catch (error) { logger.error( "Failed to fetch or render the favicon: {error}", { error }, ); layout = [""]; } } else { layout = [""]; } spinner.succeed("NodeInfo document fetched."); print(message``); let i = 0; const next = () => { i++; if (i >= layout.length) layout.push(" ".repeat(defaultWidth)); return i; }; layout[i] += colors.bold(url.host); layout[next()] += colors.dim("=".repeat(url.host.length)); layout[next()] += colors.bold(colors.dim("Software:")); layout[next()] += ` ${nodeInfo.software.name} v${nodeInfo.software.version}`; if (nodeInfo.software.homepage != null) { layout[next()] += ` ${nodeInfo.software.homepage.href}`; } if (nodeInfo.software.repository != null) { layout[next()] += " " + colors.dim(nodeInfo.software.repository.href); } if (nodeInfo.protocols.length > 0) { layout[next()] += colors.bold(colors.dim("Protocols:")); for (const protocol of nodeInfo.protocols) { layout[next()] += ` ${protocol}`; } } if (nodeInfo.services?.inbound?.length ?? 0 > 0) { layout[next()] += colors.bold(colors.dim("Inbound services:")); for (const service of nodeInfo.services?.inbound ?? []) { layout[next()] += ` ${service}`; } } if (nodeInfo.services?.outbound?.length ?? 0 > 0) { layout[next()] += colors.bold(colors.dim("Outbound services:")); for (const service of nodeInfo.services?.outbound ?? []) { layout[next()] += ` ${service}`; } } if ( nodeInfo.usage?.users != null && (nodeInfo.usage.users.total != null || nodeInfo.usage.users.activeHalfyear != null || nodeInfo.usage.users.activeMonth != null) ) { layout[next()] += colors.bold(colors.dim("Users:")); if (nodeInfo.usage.users.total != null) { layout[next()] += ` ${nodeInfo.usage.users.total.toLocaleString("en-US")} ` + colors.dim("(total)"); } if (nodeInfo.usage.users.activeHalfyear != null) { layout[next()] += ` ${nodeInfo.usage.users.activeHalfyear.toLocaleString("en-US")} ` + colors.dim("(active half year)"); } if (nodeInfo.usage.users.activeMonth != null) { layout[next()] += ` ${nodeInfo.usage.users.activeMonth.toLocaleString("en-US")} ` + colors.dim("(active month)"); } } if (nodeInfo.usage?.localPosts != null) { layout[next()] += colors.bold(colors.dim("Local posts: ")); layout[next()] += " " + nodeInfo.usage.localPosts.toLocaleString("en-US"); } if (nodeInfo.usage?.localComments != null) { layout[next()] += colors.bold(colors.dim("Local comments:")); layout[next()] += " " + nodeInfo.usage.localComments.toLocaleString("en-US"); } if (nodeInfo.openRegistrations != null) { layout[next()] += colors.bold(colors.dim("Open registrations:")); layout[next()] += " " + (nodeInfo.openRegistrations ? "Yes" : "No"); } if ( command.metadata && nodeInfo.metadata != null && Object.keys(nodeInfo.metadata).length > 0 ) { layout[next()] += colors.bold(colors.dim("Metadata:")); for (const [key, value] of Object.entries(nodeInfo.metadata)) { layout[next()] += ` ${colors.dim(key + ":")} ${ indent( typeof value === "string" ? value : formatObject(value), defaultWidth + 4 + key.length, ) }`; } } console.log(layout.join("\n")); } function indent(text: string, depth: number) { return text.replace(/\n/g, "\n" + " ".repeat(depth)); } const LINK_REGEXP = //ig; const LINK_ATTRS_REGEXP = /(?:\s+([-a-z]+)=("[^"]*"|'[^']*'|[^\s]+))/ig; export async function getFaviconUrl( url: string | URL, userAgent?: string, ): Promise { const { response, url: finalUrl } = await fetchFaviconResource( url, userAgent, ); const text = await response.text(); let svgCandidate: URL | undefined; for (const match of text.matchAll(LINK_REGEXP)) { const attrs: Record = {}; for (const attrMatch of match[1].matchAll(LINK_ATTRS_REGEXP)) { const [, key, value] = attrMatch; attrs[key] = value.startsWith('"') || value.startsWith("'") ? value.slice(1, -1) : value; } const rel = attrs.rel?.toLowerCase()?.trim()?.split(/\s+/) ?? []; if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue; if ("href" in attrs) { const parsedUrl = new URL(attrs.href, finalUrl); if ( parsedUrl.pathname.toLowerCase().endsWith(".svg") || attrs.type?.toLowerCase()?.trim() === "image/svg+xml" ) { svgCandidate ??= parsedUrl; continue; } const tokens = attrs.sizes?.match(/\d+x\d+/g); if ( tokens != null && !tokens.some((token) => { const [w, h] = token.split("x").map((v) => Number.parseInt(v)); return w >= 38 && h >= 19; }) ) continue; return parsedUrl; } } return svgCandidate ?? new URL("/favicon.ico", finalUrl); } function checkTerminalColorSupport(): "truecolor" | "256color" | "none" { // Check if colors are explicitly disabled const noColor = process.env.NO_COLOR; if (noColor != null && noColor !== "") { return "none"; } // Check for true color (24-bit) support const colorTerm = process.env.COLORTERM; if ( colorTerm != null && (colorTerm.includes("24bit") || colorTerm.includes("truecolor")) ) { return "truecolor"; } // Check for xterm 256-color support const term = process.env.TERM; if ( term != null && (term.includes("256color") || term.includes("xterm") || term === "screen" || term === "tmux") ) { return "256color"; } // Fallback: assume basic color support if TERM is set if (term != null && term !== "dumb") { return "256color"; } // Check for Windows Terminal support // FIXME: WT_SESSION is not a reliable way to check for Windows Terminal support const isWindows = os.platform() === "win32"; const isWT = process.env.WT_SESSION; if (isWindows && isWT != null && isWT !== "") { return "truecolor"; } return "none"; } const DEFAULT_IMAGE_WIDTH = 38; const ASCII_CHARS = // cSpell: disable "█▓▒░@#B8&WM%*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "; // cSpell: enable const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; const findClosestIndex = (value: number): number => { let minDiff = Infinity; let closestIndex = 0; for (let idx = 0; idx < CUBE_VALUES.length; idx++) { const diff = Math.abs(value - CUBE_VALUES[idx]); if (diff < minDiff) { minDiff = diff; closestIndex = idx; } } return closestIndex; }; export function rgbTo256Color(r: number, g: number, b: number): number { // Check if it's a grayscale color first (when all RGB values are very close) const gray = Math.round((r + g + b) / 3); const isGrayscale = Math.abs(r - gray) <= 5 && Math.abs(g - gray) <= 5 && Math.abs(b - gray) <= 5; // Handle grayscale colors (colors 232-255) - but exclude exact cube values if (isGrayscale) { const isExactCubeValue = CUBE_VALUES.includes(r) && r === g && g === b; if (!isExactCubeValue) { if (gray < 8) return 232; // Darkest grayscale if (gray > 238) return 255; // Brightest grayscale // Map to grayscale range 232-255 (24 levels) // XTerm grayscale: 8, 18, 28, ..., 238 maps to 232, 233, 234, ..., 255 const grayIndex = Math.round((gray - 8) / 10); return Math.max(232, Math.min(255, 232 + grayIndex)); } } // Handle RGB colors (colors 16-231) // XTerm 256 color cube values: [0, 95, 135, 175, 215, 255] const r6 = findClosestIndex(r); const g6 = findClosestIndex(g); const b6 = findClosestIndex(b); return 16 + (36 * r6) + (6 * g6) + b6; } export function getAsciiArt( image: Awaited>, width = DEFAULT_IMAGE_WIDTH, colorSupport: "truecolor" | "256color" | "none", colors: ChalkInstance, ): string { const ratio = image.width / image.height; const height = Math.round( width / ratio * 0.5, // Multiply by 0.5 because characters are taller than they are wide. ); image.resize({ w: width, h: height }); let art = ""; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const pixel = image.getPixelColor(x, y); const color = intToRGBA(pixel); if (color.a < 1) { art += " "; continue; } const brightness = (color.r + color.g + color.b) / 3; const charIndex = Math.round( (brightness / 255) * (ASCII_CHARS.length - 1), ); const char = ASCII_CHARS[charIndex]; if (colorSupport === "truecolor") { art += colors.rgb(color.r, color.g, color.b)(char); } else if (colorSupport === "256color") { const colorIndex = rgbTo256Color(color.r, color.g, color.b); art += colors.ansi256(colorIndex)(char); } else { art += char; } } if (y < height - 1) art += "\n"; } return art; }