From 10a31d068ed86d3a3c1485bb0888d41f274297a1 Mon Sep 17 00:00:00 2001 From: Nikita Karamov Date: Tue, 3 Sep 2024 19:19:27 +0200 Subject: [PATCH] WIP --- .gitignore | 11 +++ .vscode/extensions.json | 5 ++ .vscode/settings.json | 17 +++++ README.md | 16 +++++ components/Button.tsx | 12 ++++ deno.json | 27 +++++++ dev.ts | 8 +++ fresh.config.ts | 7 ++ fresh.gen.ts | 31 ++++++++ i18n/engine.ts | 78 ++++++++++++++++++++ i18n/translations.ts | 34 +++++++++ i18n/translations/ar.json | 23 ++++++ i18n/translations/de.json | 25 +++++++ i18n/translations/en.json | 25 +++++++ i18n/translations/es.json | 25 +++++++ i18n/translations/fr.json | 25 +++++++ i18n/translations/nl.json | 25 +++++++ i18n/translations/ru.json | 25 +++++++ i18n/translations/uk.json | 13 ++++ islands/Counter.tsx | 16 +++++ lib/instance.ts | 60 ++++++++++++++++ lib/nodeinfo.ts | 61 ++++++++++++++++ lib/project.ts | 64 +++++++++++++++++ lib/response.ts | 30 ++++++++ lib/url.ts | 35 +++++++++ main.ts | 13 ++++ routes/_404.tsx | 27 +++++++ routes/_app.tsx | 22 ++++++ routes/_middleware.ts | 35 +++++++++ routes/api/detect/[domain].ts | 37 ++++++++++ routes/api/instances.ts | 15 ++++ routes/greet/[name].tsx | 5 ++ routes/index.tsx | 71 +++++++++++++++++++ static/favicon.ico | Bin 0 -> 22382 bytes static/logo.svg | 6 ++ static/styles.css | 129 ++++++++++++++++++++++++++++++++++ 36 files changed, 1058 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 components/Button.tsx create mode 100644 deno.json create mode 100755 dev.ts create mode 100644 fresh.config.ts create mode 100644 fresh.gen.ts create mode 100644 i18n/engine.ts create mode 100644 i18n/translations.ts create mode 100644 i18n/translations/ar.json create mode 100644 i18n/translations/de.json create mode 100644 i18n/translations/en.json create mode 100644 i18n/translations/es.json create mode 100644 i18n/translations/fr.json create mode 100644 i18n/translations/nl.json create mode 100644 i18n/translations/ru.json create mode 100644 i18n/translations/uk.json create mode 100644 islands/Counter.tsx create mode 100644 lib/instance.ts create mode 100644 lib/nodeinfo.ts create mode 100644 lib/project.ts create mode 100644 lib/response.ts create mode 100644 lib/url.ts create mode 100644 main.ts create mode 100644 routes/_404.tsx create mode 100644 routes/_app.tsx create mode 100644 routes/_middleware.ts create mode 100644 routes/api/detect/[domain].ts create mode 100644 routes/api/instances.ts create mode 100644 routes/greet/[name].tsx create mode 100644 routes/index.tsx create mode 100644 static/favicon.ico create mode 100644 static/logo.svg create mode 100644 static/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b4bdef --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Fresh build directory +_fresh/ +# npm dependencies +node_modules/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..09cf720 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "denoland.vscode-deno" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a5f0701 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "deno.enable": true, + "deno.lint": true, + "editor.defaultFormatter": "denoland.vscode-deno", + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[javascript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec0e33e --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# Fresh project + +Your new Fresh project is ready to go. You can follow the Fresh "Getting +Started" guide here: https://fresh.deno.dev/docs/getting-started + +### Usage + +Make sure to install Deno: https://deno.land/manual/getting_started/installation + +Then start the project: + +``` +deno task start +``` + +This will watch the project directory and restart as necessary. diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..f1b80a0 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,12 @@ +import { JSX } from "preact"; +import { IS_BROWSER } from "$fresh/runtime.ts"; + +export function Button(props: JSX.HTMLAttributes) { + return ( + +

{props.count}

+ + + ); +} diff --git a/lib/instance.ts b/lib/instance.ts new file mode 100644 index 0000000..5a23c7e --- /dev/null +++ b/lib/instance.ts @@ -0,0 +1,60 @@ +/*! + * This file is part of Share₂Fedi + * https://github.com/kytta/share2fedi + * + * SPDX-FileCopyrightText: © 2023 Nikita Karamov + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { supportedProjects } from "$lib/project.ts"; + +interface Instance { + domain: string; + score: number; + active_users_monthly: number; + total_users: number; +} + +const getInstancesForProject = async ( + project: keyof typeof supportedProjects, +): Promise => { + let instances: Instance[]; + try { + const response = await fetch("https://api.fediverse.observer/", { + headers: { + Accept: "*/*", + "Accept-Language": "en;q=1.0", + "Content-Type": "application/json", + }, + 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 => { + 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); +}; diff --git a/lib/nodeinfo.ts b/lib/nodeinfo.ts new file mode 100644 index 0000000..de0ec43 --- /dev/null +++ b/lib/nodeinfo.ts @@ -0,0 +1,61 @@ +/*! + * This file is part of Share₂Fedi + * https://github.com/kytta/share2fedi + * + * SPDX-FileCopyrightText: © 2023 Nikita Karamov + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { normalizeURL } from "$lib/url.ts"; + +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 => { + 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; +}; diff --git a/lib/project.ts b/lib/project.ts new file mode 100644 index 0000000..6307bea --- /dev/null +++ b/lib/project.ts @@ -0,0 +1,64 @@ +/*! + * This file is part of Share₂Fedi + * https://github.com/kytta/share2fedi + * + * SPDX-FileCopyrightText: © 2023 Nikita Karamov + * 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 = { + 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, +}; diff --git a/lib/response.ts b/lib/response.ts new file mode 100644 index 0000000..6da9537 --- /dev/null +++ b/lib/response.ts @@ -0,0 +1,30 @@ +/*! + * This file is part of Share₂Fedi + * https://github.com/kytta/share2fedi + * + * SPDX-FileCopyrightText: © 2023 Nikita Karamov + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const json = ( + body: unknown, + status: number = 200, + headers: Record = {}, +) => { + 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, + ); +}; diff --git a/lib/url.ts b/lib/url.ts new file mode 100644 index 0000000..224648b --- /dev/null +++ b/lib/url.ts @@ -0,0 +1,35 @@ +/*! + * This file is part of Share₂Fedi + * https://github.com/kytta/share2fedi + * + * SPDX-FileCopyrightText: © 2023 Nikita Karamov + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Adds missing "https://" and ending slash to the URL + * + * @param url URL to normalize + * @return normalized URL + */ +export const normalizeURL = (url: string): string => { + if (!(url.startsWith("https://") || url.startsWith("http://"))) { + url = "https://" + url; + } + if (!url.endsWith("/")) { + url += "/"; + } + return url; +}; + +export const getUrlDomain = (url: string | URL): string => { + if (typeof url === "string") { + url = url.trim(); + + if (!/^https?:\/\//.test(url)) { + url = `https://${url}`; + } + } + + return new URL(url).host; +}; diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..404bfb0 --- /dev/null +++ b/main.ts @@ -0,0 +1,13 @@ +/// +/// +/// +/// +/// + +import "@std/dotenv/load"; + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; +import config from "./fresh.config.ts"; + +await start(manifest, config); diff --git a/routes/_404.tsx b/routes/_404.tsx new file mode 100644 index 0000000..c63ae2e --- /dev/null +++ b/routes/_404.tsx @@ -0,0 +1,27 @@ +import { Head } from "$fresh/runtime.ts"; + +export default function Error404() { + return ( + <> + + 404 - Page not found + +
+
+ the Fresh logo: a sliced lemon dripping with juice +

404 - Page not found

+

+ The page you were looking for doesn't exist. +

+ Go back home +
+
+ + ); +} diff --git a/routes/_app.tsx b/routes/_app.tsx new file mode 100644 index 0000000..36b1ea2 --- /dev/null +++ b/routes/_app.tsx @@ -0,0 +1,22 @@ +import { type PageProps } from "$fresh/server.ts"; + +export default function App({ Component, state }: PageProps) { + return ( + + + + Share₂Fedi + + + + + + + + ); +} diff --git a/routes/_middleware.ts b/routes/_middleware.ts new file mode 100644 index 0000000..9188288 --- /dev/null +++ b/routes/_middleware.ts @@ -0,0 +1,35 @@ +import { FreshContext } from "$fresh/server.ts"; +import { defaultLanguage, languages } from "$i18n/translations.ts"; + +interface State { + languages: string[]; + prerenderLanguage: string; +} +const chooseLanguage = (localeTags: string[]): string => { + for (const tag of localeTags) { + const locale = new Intl.Locale(tag); + const minimized = locale.minimize(); + + for (const candidate of [locale.baseName, minimized.baseName]) { + if (candidate in languages) { + return candidate; + } + } + } + + return defaultLanguage; +}; + +export function handler( + req: Request, + ctx: FreshContext, +) { + const acceptLanguages = req.headers.get("Accept-Language") + ?.split(",") + .filter(Boolean) + .map((tag) => tag.split(";")[0].trim()) + .filter((tag) => tag !== "*") ?? []; + ctx.state.prerenderLanguage = chooseLanguage(acceptLanguages); + + return ctx.next(); +} diff --git a/routes/api/detect/[domain].ts b/routes/api/detect/[domain].ts new file mode 100644 index 0000000..74695b2 --- /dev/null +++ b/routes/api/detect/[domain].ts @@ -0,0 +1,37 @@ +import type { FreshContext, Handlers } from "$fresh/server.ts"; +import { getSoftwareName } from "$lib/nodeinfo.ts"; +import { type ProjectPublishConfig, supportedProjects } from "$lib/project.ts"; +import { error, json } from "$lib/response.ts"; + +type Detection = { + domain: string; + project: keyof typeof supportedProjects; +} & ProjectPublishConfig; + +export const handler: Handlers = { + async GET(_req: Request, ctx: FreshContext): Promise { + const domain = ctx.params.domain; + + const softwareName = await getSoftwareName(domain); + if (softwareName === undefined) { + return error("Could not detect Fediverse project."); + } + if (!(softwareName in supportedProjects)) { + return error(`Fediverse project "${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", + }, + ); + }, +}; diff --git a/routes/api/instances.ts b/routes/api/instances.ts new file mode 100644 index 0000000..850eac4 --- /dev/null +++ b/routes/api/instances.ts @@ -0,0 +1,15 @@ +import type { FreshContext, Handlers } from "$fresh/server.ts"; +import { getPopularInstanceDomains } from "$lib/instance.ts"; +import { json } from "$lib/response.ts"; + +export const handler: Handlers = { + async GET(_req: Request, _ctx: FreshContext): Promise { + const popularInstanceDomains = await getPopularInstanceDomains(); + + return json(popularInstanceDomains, 200, { + "Cache-Control": popularInstanceDomains.length > 0 + ? "public, s-maxage=86400, max-age=604800" + : "public, s-maxage=60, max-age=3600", + }); + }, +}; diff --git a/routes/greet/[name].tsx b/routes/greet/[name].tsx new file mode 100644 index 0000000..9c06827 --- /dev/null +++ b/routes/greet/[name].tsx @@ -0,0 +1,5 @@ +import { PageProps } from "$fresh/server.ts"; + +export default function Greet(props: PageProps) { + return
Hello {props.params.name}
; +} diff --git a/routes/index.tsx b/routes/index.tsx new file mode 100644 index 0000000..27ba339 --- /dev/null +++ b/routes/index.tsx @@ -0,0 +1,71 @@ +import type { FreshContext, Handlers } from "$fresh/server.ts"; +import { useSignal } from "@preact/signals"; +import Counter from "../islands/Counter.tsx"; +import { type ProjectPublishConfig, supportedProjects } from "$lib/project.ts"; +import { getUrlDomain } from "$lib/url.ts"; +import { seeOther } from "@http/response/see-other"; + +type Detection = { + domain: string; + project: keyof typeof supportedProjects; +} & ProjectPublishConfig; + +import { z } from "zod"; + +const postSchema = z.object({ + text: z.string({ + required_error: "Please enter post text.", + }), + instance: z.string({ + required_error: "Please enter instance domain.", + }), +}); + +type POST = z.infer; + +export const handler: Handlers = { + async POST(req: Request, ctx: FreshContext): Promise { + const formData = await req.formData(); + const form = postSchema.safeParse(formData); + + if (!form.success) { + const errors = form.error?.format(); + return await ctx.render({ errors }); + } + + const instance = getUrlDomain(form.data.instance); + const detectResponse = await fetch( + new URL(`/api/detect/${instance}`, ctx.url), + ); + const { domain, endpoint, params } = await detectResponse.json(); + const publishUrl = new URL(endpoint, `https://${domain}/`); + publishUrl.search = new URLSearchParams([ + [params.text, form.data.text], + ]).toString(); + + return seeOther(publishUrl); + }, +}; + +export default function Home() { + const count = useSignal(3); + return ( +
+
+ the Fresh logo: a sliced lemon dripping with juice +

Welcome to Fresh

+

+ Try updating this message in the + ./routes/index.tsx file, and refresh. +

+ +
+
+ ); +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1cfaaa2193b0f210107a559f7421569f57a25388 GIT binary patch literal 22382 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 bck@HGWY%2idMme??%p8ZW3z(%VE+9-Ofn0d literal 0 HcmV?d00001 diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..ef2fbe4 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..e94132d --- /dev/null +++ b/static/styles.css @@ -0,0 +1,129 @@ + +*, +*::before, +*::after { + box-sizing: border-box; +} +* { + margin: 0; +} +button { + color: inherit; +} +button, [role="button"] { + cursor: pointer; +} +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 1em; +} +img, +svg { + display: block; +} +img, +video { + max-width: 100%; + height: auto; +} + +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.my-6 { + margin-bottom: 1.5rem; + margin-top: 1.5rem; +} +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} +.mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; +} +.my-4 { + margin-bottom: 1rem; + margin-top: 1rem; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} +.py-8 { + padding-bottom: 2rem; + padding-top: 2rem; +} +.bg-\[\#86efac\] { + background-color: #86efac; +} +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} +.py-6 { + padding-bottom: 1.5rem; + padding-top: 1.5rem; +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.py-1 { + padding-bottom: 0.25rem; + padding-top: 0.25rem; +} +.border-gray-500 { + border-color: #6b7280; +} +.bg-white { + background-color: #fff; +} +.flex { + display: flex; +} +.gap-8 { + grid-gap: 2rem; + gap: 2rem; +} +.font-bold { + font-weight: 700; +} +.max-w-screen-md { + max-width: 768px; +} +.flex-col { + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.border-2 { + border-width: 2px; +} +.rounded { + border-radius: 0.25rem; +} +.hover\:bg-gray-200:hover { + background-color: #e5e7eb; +} +.tabular-nums { + font-variant-numeric: tabular-nums; +}