Better API code structure

This commit is contained in:
Nikita Karamov 2023-09-02 17:53:16 +02:00
parent 4f3488c427
commit 6fdeee1269
No known key found for this signature in database
GPG key ID: 41D6F71EE78E77CD
12 changed files with 265 additions and 227 deletions

View file

@ -87,7 +87,7 @@ const { prefilledInstance } = Astro.props;
</style> </style>
<script> <script>
import { getUrlDomain, normalizeURL } from "@scripts/util"; import { getUrlDomain, normalizeURL } from "@lib/url";
import { $popularInstances } from "@stores/popular-instances"; import { $popularInstances } from "@stores/popular-instances";
import { $savedInstances, save } from "@stores/saved-instances"; import { $savedInstances, save } from "@stores/saved-instances";

60
src/lib/instance.ts Normal file
View file

@ -0,0 +1,60 @@
/*!
* This file is part of ShareFedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { supportedProjects } from "./project";
interface Instance {
domain: string;
score: number;
active_users_monthly: number;
total_users: number;
}
const getInstancesForProject = async (
project: keyof typeof supportedProjects,
): Promise<Instance[]> => {
let instances: Instance[];
try {
const response = await fetch("https://api.fediverse.observer/", {
headers: {
Accept: "*/*",
"Accept-Language": "en;q=1.0",
"Content-Type": "application/json",
},
referrer: "https://api.fediverse.observer/",
body: JSON.stringify({
query: `{nodes(status:"UP",softwarename:"${project}"){domain score active_users_monthly total_users}}`,
}),
method: "POST",
});
const json = await response.json();
instances = json.data.nodes;
} catch (error) {
console.error(`Could not fetch instances for "${project}"`, error);
return [];
}
return instances.filter(
(instance) =>
instance.score > 90 &&
// sanity check for some spammy-looking instances
instance.total_users >= instance.active_users_monthly,
);
};
export const getPopularInstanceDomains = async (): Promise<string[]> => {
const instancesPerProject = await Promise.all(
Object.keys(supportedProjects).map((project) =>
getInstancesForProject(project),
),
);
const instances = instancesPerProject.flat();
instances.sort((a, b) => b.active_users_monthly - a.active_users_monthly);
return instances.slice(0, 200).map((instance) => instance.domain);
};

60
src/lib/nodeinfo.ts Normal file
View file

@ -0,0 +1,60 @@
/*!
* This file is part of ShareFedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { normalizeURL } from "@lib/url";
interface NodeInfoList {
links: {
rel: string;
href: string;
}[];
}
interface NodeInfo {
software: {
name: string;
version: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export const getSoftwareName = async (
domain: string,
): Promise<string | undefined> => {
const nodeInfoListUrl = new URL(
"/.well-known/nodeinfo",
normalizeURL(domain),
);
let nodeInfoList: NodeInfoList;
try {
const nodeInfoListResponse = await fetch(nodeInfoListUrl);
nodeInfoList = await nodeInfoListResponse.json();
} catch (error) {
console.error("Could not fetch '.well-known/nodeinfo':", error);
return undefined;
}
for (const link of nodeInfoList.links) {
if (
/^http:\/\/nodeinfo\.diaspora\.software\/ns\/schema\/(1\.0|1\.1|2\.0|2\.1)/.test(
link.rel,
)
) {
const nodeInfoResponse = await fetch(link.href);
const nodeInfo = (await nodeInfoResponse.json()) as NodeInfo;
return nodeInfo.software.name;
}
}
// not found
console.warn("No NodeInfo found for domain:", domain);
return undefined;
};

64
src/lib/project.ts Normal file
View file

@ -0,0 +1,64 @@
/*!
* This file is part of ShareFedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface ProjectPublishConfig {
endpoint: string;
params: {
text: string;
};
}
const mastodonConfig: ProjectPublishConfig = {
endpoint: "share",
params: {
text: "text",
},
};
const misskeyConfig: ProjectPublishConfig = {
endpoint: "share",
params: {
text: "text",
},
};
/**
* Mapping of the supported fediverse projects.
*
* The keys of this mapping can be used as keys for the fediverse.observer API,
* icon names, etc.
*/
export const supportedProjects: Record<string, ProjectPublishConfig> = {
calckey: misskeyConfig,
fedibird: mastodonConfig,
firefish: misskeyConfig,
foundkey: misskeyConfig,
friendica: {
endpoint: "compose",
params: {
text: "body",
},
},
glitchcafe: mastodonConfig,
gnusocial: {
endpoint: "notice/new",
params: {
text: "status_textarea",
},
},
hometown: mastodonConfig,
hubzilla: {
endpoint: "rpost",
params: {
text: "body",
},
},
mastodon: mastodonConfig,
meisskey: misskeyConfig,
misskey: misskeyConfig,
};

30
src/lib/response.ts Normal file
View file

@ -0,0 +1,30 @@
/*!
* This file is part of ShareFedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const json = (
body: unknown,
status: number = 200,
headers: Record<string, string> = {},
) => {
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
...headers,
},
status,
});
};
export const error = (message: string, status: number = 400) => {
return json(
{
error: message,
},
status,
);
};

View file

@ -6,130 +6,38 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { getSoftwareName } from "@lib/nodeinfo";
import { ProjectPublishConfig, supportedProjects } from "@lib/project";
import { error, json } from "@lib/response";
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { FediverseProject } from "@scripts/constants";
import { normalizeURL } from "@scripts/util";
interface FediverseProjectData { export type Detection = {
publishEndpoint: string; domain: string;
params: { project: keyof typeof supportedProjects;
text: string; } & ProjectPublishConfig;
};
}
const mastodonSettings = {
publishEndpoint: "share",
params: {
text: "text",
},
};
const misskeySettings = {
publishEndpoint: "share",
params: {
text: "text",
},
};
const PROJECTS: Map<FediverseProject, FediverseProjectData> = new Map()
.set(FediverseProject.Mastodon, mastodonSettings)
.set(FediverseProject.Fedibird, mastodonSettings)
.set(FediverseProject.GlitchCafe, mastodonSettings)
.set(FediverseProject.Hometown, mastodonSettings)
.set(FediverseProject.Misskey, misskeySettings)
.set(FediverseProject.Calckey, misskeySettings)
.set(FediverseProject.Firefish, misskeySettings)
.set(FediverseProject.FoundKey, misskeySettings)
.set(FediverseProject.Meisskey, misskeySettings)
.set(FediverseProject.GNUSocial, {
publishEndpoint: "/notice/new",
params: {
text: "status_textarea",
},
})
.set(FediverseProject.Friendica, {
publishEndpoint: "compose",
params: {
text: "body",
},
})
.set(FediverseProject.Hubzilla, {
publishEndpoint: "rpost",
params: {
text: "body",
},
});
interface NodeInfoList {
links: {
rel: string;
href: string;
}[];
}
interface NodeInfo {
[key: string]: unknown;
software: {
[key: string]: unknown;
name: string;
};
}
type NonEmptyArray<T> = [T, ...T[]];
function isNotEmpty<T>(array: T[]): array is NonEmptyArray<T> {
return array.length > 0;
}
const checkNodeInfo = async (domain: string): Promise<FediverseProject> => {
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 }) => { export const get: APIRoute = async ({ params }) => {
const domain = params.domain as string; const domain = params.domain as string;
try { const softwareName = await getSoftwareName(domain);
const projectId = await checkNodeInfo(domain); if (softwareName === undefined) {
return error("Could not detect software; NodeInfo not present.");
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": "public, s-maxage=86400, max-age=604800",
"Content-Type": "application/json",
},
},
);
} catch {
return new Response(JSON.stringify({ error: "Couldn't detect instance" }), {
status: 404,
headers: {
"Content-Type": "application/json",
},
});
} }
if (!(softwareName in supportedProjects)) {
return error(`"${softwareName}" is not supported yet.`);
}
const publishConfig = supportedProjects[softwareName] as ProjectPublishConfig;
return json(
{
domain,
project: softwareName,
...publishConfig,
},
200,
{
"Cache-Control": "public, s-maxage=86400, max-age=604800",
},
);
}; };

View file

@ -7,70 +7,16 @@
*/ */
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { FediverseProject } from "@scripts/constants"; import { getPopularInstanceDomains } from "@lib/instance";
import { json } from "@lib/response";
interface ProjectInstance {
domain: string;
score: number;
active_users_monthly: number;
total_users: number;
}
const PROJECTS = Object.values(FediverseProject);
export const fetchInstances = async (
projectId: string,
): Promise<ProjectInstance[]> => {
const response = await fetch("https://api.fediverse.observer/", {
headers: {
Accept: "*/*",
"Accept-Language": "en;q=1.0",
"Content-Type": "application/json",
},
referrer: "https://api.fediverse.observer/",
body: JSON.stringify({
query: `{nodes(status:"UP",softwarename:"${projectId}"){domain score active_users_monthly total_users}}`,
}),
method: "POST",
});
const json = await response.json();
const instances: ProjectInstance[] = json.data.nodes;
return instances.filter(
(instance) =>
instance.score > 90 &&
instance.total_users >= instance.active_users_monthly,
);
};
export const get: APIRoute = async () => { export const get: APIRoute = async () => {
try { const popularInstanceDomains = await getPopularInstanceDomains();
const response = await Promise.all(
PROJECTS.map((projectId) => fetchInstances(projectId)),
);
const instances = response.flat();
instances.sort((a, b) => {
return b.active_users_monthly - a.active_users_monthly;
});
return new Response( return json(popularInstanceDomains, 200, {
JSON.stringify( "Cache-Control":
instances popularInstanceDomains.length > 0
.slice(0, 200) ? "public, s-maxage=86400, max-age=604800"
.map((instance: ProjectInstance) => instance.domain), : "public, s-maxage=60, max-age=3600",
), });
{
headers: {
"Cache-Control": "public, s-maxage=86400, max-age=604800",
"Content-Type": "application/json",
},
},
);
} catch (error) {
console.error("Could not fetch instances:", error);
return new Response(JSON.stringify([]), {
headers: {
"Content-Type": "application/json",
},
});
}
}; };

View file

@ -6,8 +6,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { getUrlDomain } from "@scripts/util"; import { getUrlDomain } from "@lib/url";
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import type { Detection } from "./detect/[domain]";
import { error } from "@lib/response";
export const post: APIRoute = async ({ redirect, request, url }) => { export const post: APIRoute = async ({ redirect, request, url }) => {
const formData = await request.formData(); const formData = await request.formData();
@ -16,18 +18,14 @@ export const post: APIRoute = async ({ redirect, request, url }) => {
const instanceHost = const instanceHost =
getUrlDomain(formData.get("instance") as string) || "mastodon.social"; getUrlDomain(formData.get("instance") as string) || "mastodon.social";
try { const response = await fetch(new URL(`/api/detect/${instanceHost}`, url));
const response = await fetch(new URL(`/api/detect/${instanceHost}`, url)); const json = await response.json();
const { host, publishEndpoint, params } = await response.json(); if (json.error) {
const publishUrl = new URL(publishEndpoint, `https://${host}/`); return error(json.error);
publishUrl.search = new URLSearchParams([[params.text, text]]).toString();
return redirect(publishUrl.toString(), 303);
} catch {
return new Response(JSON.stringify({ error: "Couldn't detect instance" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
} }
const { domain, endpoint, params } = json as Detection;
const publishUrl = new URL(endpoint, `https://${domain}/`);
publishUrl.search = new URLSearchParams([[params.text, text]]).toString();
return redirect(publishUrl.toString(), 303);
}; };

View file

@ -1,28 +0,0 @@
/*!
* This file is part of ShareFedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Enumeration of the supported fediverse projects.
*
* The values of this enum are used as the keys for the fediverse.observer API,
* as the icon names, etc.
*/
export enum FediverseProject {
Calckey = "calckey",
GlitchCafe = "glitchcafe",
Fedibird = "fedibird",
Firefish = "firefish",
FoundKey = "foundkey",
Friendica = "friendica",
GNUSocial = "gnusocial",
Hometown = "hometown",
Hubzilla = "hubzilla",
Mastodon = "mastodon",
Meisskey = "meisskey",
Misskey = "misskey",
}

View file

@ -7,7 +7,7 @@
*/ */
import { persistentAtom } from "@nanostores/persistent"; import { persistentAtom } from "@nanostores/persistent";
import { getUrlDomain } from "@scripts/util"; import { getUrlDomain } from "@lib/url";
import { action, onMount } from "nanostores"; import { action, onMount } from "nanostores";
const OLD_LOCAL_STORAGE_KEY = "recentInstances"; const OLD_LOCAL_STORAGE_KEY = "recentInstances";

View file

@ -14,7 +14,7 @@
"@i18n/*": ["src/i18n/*"], "@i18n/*": ["src/i18n/*"],
"@layouts/*": ["src/layouts/*"], "@layouts/*": ["src/layouts/*"],
"@pages/*": ["src/pages/*"], "@pages/*": ["src/pages/*"],
"@scripts/*": ["src/scripts/*"], "@lib/*": ["src/lib/*"],
"@stores/*": ["src/stores/*"], "@stores/*": ["src/stores/*"],
"@styles/*": ["src/styles/*"] "@styles/*": ["src/styles/*"]
} }