diff --git a/src/pages/api/detect/[domain].ts b/src/pages/api/detect/[domain].ts new file mode 100644 index 0000000..a005900 --- /dev/null +++ b/src/pages/api/detect/[domain].ts @@ -0,0 +1,146 @@ +/*! + * © 2023 Nikita Karamov + * Licensed under AGPL v3 or later + */ + +import type { APIRoute } from "astro"; +import { FediverseProject } from "../../../constants"; +import { normalizeURL } from "../../../util"; + +interface FediverseProjectData { + publishEndpoint: string; + params: { + text: string; + }; +} + +const PROJECTS: Map = new Map([ + [ + FediverseProject.Mastodon, + { + publishEndpoint: "share", + params: { + text: "text", + }, + }, + ], + [ + FediverseProject.GNUSocial, + { + publishEndpoint: "/notice/new", + params: { + text: "status_textarea", + }, + }, + ], + [ + FediverseProject.Pleroma, + { + publishEndpoint: "share", + params: { + text: "message", + }, + }, + ], + [ + FediverseProject.Friendica, + { + publishEndpoint: "compose", + params: { + text: "body", + }, + }, + ], + [ + FediverseProject.Hubzilla, + { + publishEndpoint: "rpost", + params: { + text: "body", + }, + }, + ], + [ + FediverseProject.Misskey, + { + publishEndpoint: "share", + params: { + text: "text", + }, + }, + ], +]); + +interface NodeInfoList { + links: { + rel: string; + href: string; + }[]; +} + +interface NodeInfo { + [key: string]: unknown; + software: { + [key: string]: unknown; + name: string; + }; +} + +type NonEmptyArray = [T, ...T[]]; +function isNotEmpty(array: T[]): array is NonEmptyArray { + return array.length > 0; +} + +const checkNodeInfo = async (domain: string): Promise => { + const nodeInfoListUrl = new URL( + "/.well-known/nodeinfo", + normalizeURL(domain), + ); + const nodeInfoListResponse = await fetch(nodeInfoListUrl); + const nodeInfoList = (await nodeInfoListResponse.json()) as NodeInfoList; + + if (isNotEmpty(nodeInfoList.links)) { + const nodeInfoUrl = nodeInfoList.links[0].href; + const nodeInfoResponse = await fetch(nodeInfoUrl); + const nodeInfo = (await nodeInfoResponse.json()) as NodeInfo; + + return nodeInfo.software.name as FediverseProject; + } else { + throw new Error(`No nodeinfo found for ${domain}`); + } +}; + +export const get: APIRoute = async ({ params }) => { + const domain = params.domain as string; + + try { + const projectId = await checkNodeInfo(domain); + + if (!PROJECTS.has(projectId)) { + throw new Error(`Unexpected project ID: ${projectId}`); + } + const projectData = PROJECTS.get(projectId) as FediverseProjectData; + return new Response( + JSON.stringify({ + host: domain, + project: projectId, + publishEndpoint: projectData.publishEndpoint, + params: projectData.params, + }), + { + status: 200, + headers: { + "Cache-Control": "s-maxage=86400, max-age=86400, public", + "Content-Type": "application/json", + }, + }, + ); + } catch { + return new Response(JSON.stringify({ error: "Couldn't detect instance" }), { + status: 404, + headers: { + "Content-Type": "application/json", + }, + }); + } +}; diff --git a/src/pages/api/detect/[host].ts b/src/pages/api/detect/[host].ts deleted file mode 100644 index 388088b..0000000 --- a/src/pages/api/detect/[host].ts +++ /dev/null @@ -1,182 +0,0 @@ -/*! - * © 2023 Nikita Karamov - * Licensed under AGPL v3 or later - */ - -import type { APIRoute } from "astro"; -import { normalizeURL } from "../../../util"; - -interface FediverseProjectBasic { - publishEndpoint: string; - params: { - text: string; - }; -} - -interface FediverseProjectCheckFunction extends FediverseProjectBasic { - check: (url: string) => Promise; -} - -interface FediverseProjectCheckUrl extends FediverseProjectBasic { - checkUrl: string; -} - -type FediverseProject = - | FediverseProjectCheckUrl - | FediverseProjectCheckFunction; - -const PROJECTS: Map = new Map([ - [ - "mastodon", - { - checkUrl: "/api/v1/instance/rules", - publishEndpoint: "share", - params: { - text: "text", - }, - }, - ], - [ - "gnuSocial", - { - checkUrl: "/api/gnusocial/config.xml", - publishEndpoint: "/notice/new", - params: { - text: "status_textarea", - }, - }, - ], - [ - "pleroma", - { - checkUrl: "/api/v1/pleroma/federation_status", - publishEndpoint: "share", - params: { - text: "message", - }, - }, - ], - [ - "friendica", - { - checkUrl: "/api/statusnet/config", - publishEndpoint: "compose", - params: { - text: "body", - }, - }, - ], - [ - "hubzilla", - { - check: async (url: string): Promise => { - const response = await fetch(url); - const htmlBody = await response.text(); - console.debug(htmlBody); - if ( - htmlBody.includes( - '', - ) - ) { - return "hubzilla"; - } - throw new Error(`${url} doesn't host Hubzilla`); - }, - checkUrl: "/.well-known/zot-info", - publishEndpoint: "rpost", - params: { - text: "body", - }, - }, - ], - [ - "misskey", - { - check: async (url: string): Promise => { - const response = await fetch(new URL("/api/meta", url), { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - detail: false, - }), - }); - const metadata = await response.json(); - if (metadata.version) { - return "misskey"; - } - throw new Error(`${url} doesn't host Misskey`); - }, - checkUrl: "/", - publishEndpoint: "share", - params: { - text: "text", - }, - }, - ], -]); - -const checkProjectUrl = ( - urlToCheck: URL, - projectId: string, -): Promise => { - return new Promise((resolve, reject) => { - fetch(urlToCheck) - .then((response) => { - if (response.ok) { - resolve(projectId); - } else { - reject(urlToCheck); - } - }) - .catch((error) => { - reject(error.toString()); - }); - }); -}; - -export const get: APIRoute = async ({ params }) => { - const host = params.host as string; - - const promises = [...PROJECTS.entries()].map(([service, project]) => { - const url = normalizeURL(host); - if (project.check !== undefined) { - return project.check(url); - } - return checkProjectUrl(new URL(project.checkUrl, url), service); - }); - try { - const projectId = await Promise.any(promises); - - if (!PROJECTS.has(projectId)) { - throw new Error(`Unexpected project ID: ${projectId}`); - } - - const project = PROJECTS.get(projectId) as FediverseProject; - - return new Response( - JSON.stringify({ - host, - project: projectId, - publishEndpoint: project.publishEndpoint, - params: project.params, - }), - { - status: 200, - headers: { - "Cache-Control": "s-maxage=86400, max-age=86400, public", - "Content-Type": "application/json", - }, - }, - ); - } catch { - return new Response(JSON.stringify({ error: "Couldn't detect instance" }), { - status: 404, - headers: { - "Content-Type": "application/json", - }, - }); - } -};