Better API code structure
This commit is contained in:
parent
4f3488c427
commit
6fdeee1269
12 changed files with 265 additions and 227 deletions
|
@ -87,7 +87,7 @@ const { prefilledInstance } = Astro.props;
|
|||
</style>
|
||||
|
||||
<script>
|
||||
import { getUrlDomain, normalizeURL } from "@scripts/util";
|
||||
import { getUrlDomain, normalizeURL } from "@lib/url";
|
||||
import { $popularInstances } from "@stores/popular-instances";
|
||||
import { $savedInstances, save } from "@stores/saved-instances";
|
||||
|
||||
|
|
60
src/lib/instance.ts
Normal file
60
src/lib/instance.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*!
|
||||
* This file is part of Share₂Fedi
|
||||
* 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
60
src/lib/nodeinfo.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*!
|
||||
* This file is part of Share₂Fedi
|
||||
* 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
64
src/lib/project.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*!
|
||||
* This file is part of Share₂Fedi
|
||||
* 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
30
src/lib/response.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*!
|
||||
* This file is part of Share₂Fedi
|
||||
* 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,
|
||||
);
|
||||
};
|
|
@ -6,130 +6,38 @@
|
|||
* 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 { FediverseProject } from "@scripts/constants";
|
||||
import { normalizeURL } from "@scripts/util";
|
||||
|
||||
interface FediverseProjectData {
|
||||
publishEndpoint: string;
|
||||
params: {
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
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 type Detection = {
|
||||
domain: string;
|
||||
project: keyof typeof supportedProjects;
|
||||
} & ProjectPublishConfig;
|
||||
|
||||
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": "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",
|
||||
},
|
||||
});
|
||||
const softwareName = await getSoftwareName(domain);
|
||||
if (softwareName === undefined) {
|
||||
return error("Could not detect software; NodeInfo not present.");
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
@ -7,70 +7,16 @@
|
|||
*/
|
||||
|
||||
import type { APIRoute } from "astro";
|
||||
import { FediverseProject } from "@scripts/constants";
|
||||
|
||||
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,
|
||||
);
|
||||
};
|
||||
import { getPopularInstanceDomains } from "@lib/instance";
|
||||
import { json } from "@lib/response";
|
||||
|
||||
export const get: APIRoute = async () => {
|
||||
try {
|
||||
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;
|
||||
});
|
||||
const popularInstanceDomains = await getPopularInstanceDomains();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
instances
|
||||
.slice(0, 200)
|
||||
.map((instance: ProjectInstance) => instance.domain),
|
||||
),
|
||||
{
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
return json(popularInstanceDomains, 200, {
|
||||
"Cache-Control":
|
||||
popularInstanceDomains.length > 0
|
||||
? "public, s-maxage=86400, max-age=604800"
|
||||
: "public, s-maxage=60, max-age=3600",
|
||||
});
|
||||
};
|
||||
|
|
|
@ -6,8 +6,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { getUrlDomain } from "@scripts/util";
|
||||
import { getUrlDomain } from "@lib/url";
|
||||
import type { APIRoute } from "astro";
|
||||
import type { Detection } from "./detect/[domain]";
|
||||
import { error } from "@lib/response";
|
||||
|
||||
export const post: APIRoute = async ({ redirect, request, url }) => {
|
||||
const formData = await request.formData();
|
||||
|
@ -16,18 +18,14 @@ export const post: APIRoute = async ({ redirect, request, url }) => {
|
|||
const instanceHost =
|
||||
getUrlDomain(formData.get("instance") as string) || "mastodon.social";
|
||||
|
||||
try {
|
||||
const response = await fetch(new URL(`/api/detect/${instanceHost}`, url));
|
||||
const { host, publishEndpoint, params } = await response.json();
|
||||
const publishUrl = new URL(publishEndpoint, `https://${host}/`);
|
||||
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 response = await fetch(new URL(`/api/detect/${instanceHost}`, url));
|
||||
const json = await response.json();
|
||||
if (json.error) {
|
||||
return error(json.error);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
/*!
|
||||
* This file is part of Share₂Fedi
|
||||
* 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",
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { persistentAtom } from "@nanostores/persistent";
|
||||
import { getUrlDomain } from "@scripts/util";
|
||||
import { getUrlDomain } from "@lib/url";
|
||||
import { action, onMount } from "nanostores";
|
||||
|
||||
const OLD_LOCAL_STORAGE_KEY = "recentInstances";
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
"@i18n/*": ["src/i18n/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@pages/*": ["src/pages/*"],
|
||||
"@scripts/*": ["src/scripts/*"],
|
||||
"@lib/*": ["src/lib/*"],
|
||||
"@stores/*": ["src/stores/*"],
|
||||
"@styles/*": ["src/styles/*"]
|
||||
}
|
||||
|
|
Reference in a new issue