From 5ee0b88f849e5bbe055d6f6ce8b27772fa89b719 Mon Sep 17 00:00:00 2001 From: nyanrus <68762426+nyanrus@users.noreply.github.com> Date: Thu, 21 May 2026 14:43:41 +0900 Subject: [PATCH] Make remote follow/vote/reply instance-based and remembered Remote interactions previously required the user's full fediverse handle every time, only to redirect to their home instance. The username part was never used to build the interaction URL. - Add /api/resolve-instance: resolves an instance domain to a remote interaction URL via NodeInfo software detection, with a canonical WebFinger template path when a full handle is supplied. Includes a basic SSRF guard and fetch timeout. Replaces /api/webfinger and /api/instance-lookup. - Remember used instances in localStorage; derive a default instance from the primary linked fediverse account for logged-in users. - Add RemoteInteractionButton: a split button ("Follow with {instance}" + a kebab menu to switch/add instances) shared by the follow, vote and discussion dialogs. First-time use shows the instance's NodeInfo title for confirmation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/RemoteDiscussionDialog.tsx | 170 +------ src/components/RemoteFollowDialog.tsx | 187 +------- src/components/RemoteInteractionButton.tsx | 414 ++++++++++++++++++ src/components/RemoteVoteDialog.tsx | 152 +------ src/hooks/useRemoteInstance.ts | 84 ++++ src/lib/remote-instance.ts | 75 ++++ src/server-entry.ts | 11 +- src/server/controllers/api/instance-lookup.ts | 71 --- .../api/resolve-instance-domain.ts | 59 +++ .../controllers/api/resolve-instance.test.ts | 51 +++ .../controllers/api/resolve-instance.ts | 131 ++++++ src/server/controllers/api/webfinger.ts | 108 ----- 12 files changed, 852 insertions(+), 661 deletions(-) create mode 100644 src/components/RemoteInteractionButton.tsx create mode 100644 src/hooks/useRemoteInstance.ts create mode 100644 src/lib/remote-instance.ts delete mode 100644 src/server/controllers/api/instance-lookup.ts create mode 100644 src/server/controllers/api/resolve-instance-domain.ts create mode 100644 src/server/controllers/api/resolve-instance.test.ts create mode 100644 src/server/controllers/api/resolve-instance.ts delete mode 100644 src/server/controllers/api/webfinger.ts diff --git a/src/components/RemoteDiscussionDialog.tsx b/src/components/RemoteDiscussionDialog.tsx index 35afd2b..a89791e 100644 --- a/src/components/RemoteDiscussionDialog.tsx +++ b/src/components/RemoteDiscussionDialog.tsx @@ -1,15 +1,5 @@ -import { type ReactNode, useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; +import type { ReactNode } from "react"; +import { RemoteInteractionButton } from "~/components/RemoteInteractionButton"; export function RemoteDiscussionDialog({ apUrl, @@ -22,155 +12,13 @@ export function RemoteDiscussionDialog({ variant?: "button" | "link" | "ghost"; className?: string; }) { - const [open, setOpen] = useState(false); - const [handle, setHandle] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [interactionUrl, setInteractionUrl] = useState(null); - const [domain, setDomain] = useState(null); - - const handleLookup = async (e: React.FormEvent) => { - e.preventDefault(); - const input = handle.trim(); - if (!input) { - setError("Please enter your fediverse handle."); - return; - } - - if (!/^@?[^@]+@[^@]+\.[^@]+$/.test(input)) { - setError("Invalid format. Use @username@domain.com"); - return; - } - - setLoading(true); - setError(""); - - try { - const response = await fetch("/api/instance-lookup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ handle: input }), - }); - - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Lookup failed."); - } - - const url = data.interactionTemplate.replace( - "{uri}", - encodeURIComponent(apUrl), - ); - setInteractionUrl(url); - setDomain(data.domain); - } catch (err) { - setError(err instanceof Error ? err.message : "Lookup failed."); - } finally { - setLoading(false); - } - }; - - const handleGo = () => { - if (!interactionUrl) return; - window.open(interactionUrl, "_blank", "noopener,noreferrer"); - setOpen(false); - }; - - const handleOpenChange = (next: boolean) => { - setOpen(next); - if (!next) { - setHandle(""); - setError(""); - setInteractionUrl(null); - setDomain(null); - setLoading(false); - } - }; - return ( - - - {variant === "link" ? ( - - ) : variant === "ghost" ? ( - - ) : ( - - )} - - - - Join the Discussion - - If you want to continue this discussion, enter your fediverse handle - to find this post on your instance. - - - - {!interactionUrl ? ( -
-
- - { - setHandle(e.target.value); - if (error) setError(""); - }} - disabled={loading} - /> - {error &&

{error}

} -
-
- - -
-
- ) : ( -
-

- You will be redirected to {domain} to find and - reply to this post. -

- {error &&

{error}

} -
- - -
-
- )} -
-
+ ); } diff --git a/src/components/RemoteFollowDialog.tsx b/src/components/RemoteFollowDialog.tsx index 9a4b780..3a65a69 100644 --- a/src/components/RemoteFollowDialog.tsx +++ b/src/components/RemoteFollowDialog.tsx @@ -1,171 +1,22 @@ -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; - -interface ActorInfo { - name?: string; - handle: string; - icon?: string; - summary?: string; - remoteFollowUrl?: string; -} - -export function RemoteFollowDialog({ actorHandle, className, children }: { actorHandle: string; className?: string; children?: React.ReactNode }) { - const [open, setOpen] = useState(false); - const [fediverseId, setFediverseId] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [actorInfo, setActorInfo] = useState(null); - - const handleLookup = async (e: React.FormEvent) => { - e.preventDefault(); - const input = fediverseId.trim(); - if (!input) { - setError("Please enter your fediverse handle."); - return; - } - - if (!/^@?[^@]+@[^@]+\.[^@]+$/.test(input)) { - setError("Invalid format. Use @username@domain.com"); - return; - } - - setLoading(true); - setError(""); - setActorInfo(null); - - try { - const response = await fetch("/api/webfinger", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fediverseId: input, actorHandle }), - }); - - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Lookup failed."); - } - if (!data.actor) { - throw new Error("User not found."); - } - setActorInfo(data.actor); - } catch (err) { - setError(err instanceof Error ? err.message : "Lookup failed."); - } finally { - setLoading(false); - } - }; - - const handleFollow = () => { - if (!actorInfo?.remoteFollowUrl) { - setError("Subscribe template not found for this instance."); - return; - } - window.open(actorInfo.remoteFollowUrl, "_blank", "noopener,noreferrer"); - setOpen(false); - }; - - const handleOpenChange = (next: boolean) => { - setOpen(next); - if (!next) { - setFediverseId(""); - setError(""); - setActorInfo(null); - setLoading(false); - } - }; - +import type { ReactNode } from "react"; +import { RemoteInteractionButton } from "~/components/RemoteInteractionButton"; + +export function RemoteFollowDialog({ + actorHandle, + className, + children, +}: { + actorHandle: string; + className?: string; + children?: ReactNode; +}) { return ( - - - {children ?? ( - - )} - - - - Remote Follow - - To follow @{actorHandle}, enter your fediverse handle. - - - - {!actorInfo ? ( -
-
- - { - setFediverseId(e.target.value); - if (error) setError(""); - }} - disabled={loading} - /> - {error &&

{error}

} -
-
- - -
-
- ) : ( -
-
- {actorInfo.icon && ( - - )} -
- {actorInfo.name && ( -

{actorInfo.name}

- )} -

- @{actorInfo.handle} -

-
-
- {error &&

{error}

} -
- - -
-
- )} -
-
+ ); } diff --git a/src/components/RemoteInteractionButton.tsx b/src/components/RemoteInteractionButton.tsx new file mode 100644 index 0000000..d4e8af2 --- /dev/null +++ b/src/components/RemoteInteractionButton.tsx @@ -0,0 +1,414 @@ +import { + type ReactNode, + cloneElement, + isValidElement, + useEffect, + useState, +} from "react"; +import { Check, EllipsisVertical, Loader2, Star, X } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Badge } from "~/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { useRemoteInstance } from "~/hooks/useRemoteInstance"; + +export type RemoteInteractionKind = "follow" | "vote" | "discussion"; + +interface ResolvedInstance { + domain: string; + title: string; + software: string | null; + interactionUrl: string; +} + +const KIND_CONFIG: Record< + RemoteInteractionKind, + { verb: string; dialogTitle: string; dialogDescription: string; idleLabel: string } +> = { + follow: { + verb: "Follow", + dialogTitle: "Remote Follow", + dialogDescription: "Follow from your own fediverse instance.", + idleLabel: "Remote Follow", + }, + vote: { + verb: "Vote", + dialogTitle: "Remote Vote", + dialogDescription: "Vote on this poll from your fediverse instance.", + idleLabel: "Remote Vote", + }, + discussion: { + verb: "Reply", + dialogTitle: "Join the Discussion", + dialogDescription: "Open this post on your own fediverse instance to reply.", + idleLabel: "Discuss on your instance", + }, +}; + +async function resolveInstance( + instance: string, + kind: RemoteInteractionKind, + target: string, +): Promise { + const res = await fetch("/api/resolve-instance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instance, + target: { type: kind === "follow" ? "handle" : "url", value: target }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Instance lookup failed."); + return data as ResolvedInstance; +} + +/** + * A remote follow / vote / reply control. Once the user has interacted from an + * instance it is remembered, so later interactions become a single click on + * `{verb} with {instance}`; the `⋮` menu switches instance or adds a new one. + */ +export function RemoteInteractionButton({ + kind, + target, + className, + trigger, + triggerVariant = "split", + triggerLabel, +}: { + kind: RemoteInteractionKind; + target: string; + className?: string; + /** Custom trigger element; when given, the split button is not rendered. */ + trigger?: ReactNode; + triggerVariant?: "split" | "button" | "ghost" | "link"; + triggerLabel?: ReactNode; +}) { + const cfg = KIND_CONFIG[kind]; + const { defaultInstance, knownInstances, remember, setDefault, remove } = + useRemoteInstance(); + + // Avoid a hydration mismatch: localStorage is only known after mount. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"input" | "confirm">("input"); + const [inputValue, setInputValue] = useState(""); + const [resolved, setResolved] = useState(null); + const [loading, setLoading] = useState(false); + const [quickLoading, setQuickLoading] = useState(false); + const [error, setError] = useState(""); + + const defaultTitle = + knownInstances.find((i) => i.domain === defaultInstance)?.title ?? + defaultInstance; + + function openDialog() { + setStep("input"); + setInputValue(defaultInstance ?? ""); + setResolved(null); + setError(""); + setOpen(true); + } + + /** One-click path: resolve a known instance and open it in a new tab. */ + async function quickInteract(instance: string) { + // Open the tab synchronously (inside the click) so it survives popup blockers. + const win = window.open("about:blank", "_blank"); + setQuickLoading(true); + setError(""); + try { + const r = await resolveInstance(instance, kind, target); + remember({ domain: r.domain, title: r.title, software: r.software }); + if (win) { + win.opener = null; + win.location.href = r.interactionUrl; + } else { + window.open(r.interactionUrl, "_blank", "noopener,noreferrer"); + } + } catch (err) { + win?.close(); + setError(err instanceof Error ? err.message : "Instance lookup failed."); + setStep("input"); + setInputValue(instance); + setOpen(true); + } finally { + setQuickLoading(false); + } + } + + /** Dialog path: look up a typed instance and show its NodeInfo title first. */ + async function handleLookup(e: React.FormEvent) { + e.preventDefault(); + const value = inputValue.trim(); + if (!value) { + setError("Enter an instance domain."); + return; + } + setLoading(true); + setError(""); + try { + const r = await resolveInstance(value, kind, target); + setResolved(r); + setStep("confirm"); + } catch (err) { + setError(err instanceof Error ? err.message : "Instance lookup failed."); + } finally { + setLoading(false); + } + } + + function handleConfirm() { + if (!resolved) return; + remember({ + domain: resolved.domain, + title: resolved.title, + software: resolved.software, + }); + window.open(resolved.interactionUrl, "_blank", "noopener,noreferrer"); + setOpen(false); + } + + const showSplit = + mounted && triggerVariant === "split" && !!defaultInstance && !trigger; + + const triggerEl = (() => { + if (trigger && isValidElement(trigger)) { + return cloneElement( + trigger as React.ReactElement<{ onClick?: () => void }>, + { onClick: openDialog }, + ); + } + if (triggerVariant === "link") { + return ( + + ); + } + if (triggerVariant === "ghost") { + return ( + + ); + } + if (!showSplit) { + return ( + + ); + } + return ( +
+ + + + + + + + {cfg.verb} from + + {knownInstances.map((inst) => ( + quickInteract(inst.domain)} + > + {inst.title} + {inst.domain === defaultInstance && ( + + )} + + ))} + + + {cfg.verb} with another instance… + + + +
+ ); + })(); + + return ( + <> + {triggerEl} + !next && setOpen(false)}> + + + {cfg.dialogTitle} + {cfg.dialogDescription} + + + {step === "input" ? ( +
+ {knownInstances.length > 0 && ( +
+ +
    + {knownInstances.map((inst) => ( +
  • + + + +
  • + ))} +
+
+ )} + +
+ +
+ { + setInputValue(e.target.value); + if (error) setError(""); + }} + disabled={loading} + /> + +
+ {error && ( +

{error}

+ )} +
+
+ ) : resolved ? ( +
+
+

{resolved.title}

+

+ {resolved.domain} +

+ {resolved.software && ( + + {resolved.software} + + )} +
+

+ You'll be sent to {resolved.title} to confirm. +

+
+ + +
+
+ ) : null} +
+
+ + ); +} diff --git a/src/components/RemoteVoteDialog.tsx b/src/components/RemoteVoteDialog.tsx index bc5691a..0c5beac 100644 --- a/src/components/RemoteVoteDialog.tsx +++ b/src/components/RemoteVoteDialog.tsx @@ -1,15 +1,4 @@ -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/components/ui/dialog"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; +import { RemoteInteractionButton } from "~/components/RemoteInteractionButton"; export function RemoteVoteDialog({ apUrl, @@ -18,139 +7,12 @@ export function RemoteVoteDialog({ apUrl: string; className?: string; }) { - const [open, setOpen] = useState(false); - const [handle, setHandle] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [interactionUrl, setInteractionUrl] = useState(null); - const [domain, setDomain] = useState(null); - - const handleLookup = async (e: React.FormEvent) => { - e.preventDefault(); - const input = handle.trim(); - if (!input) { - setError("Please enter your fediverse handle."); - return; - } - - if (!/^@?[^@]+@[^@]+\.[^@]+$/.test(input)) { - setError("Invalid format. Use @username@domain.com"); - return; - } - - setLoading(true); - setError(""); - - try { - const response = await fetch("/api/instance-lookup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ handle: input }), - }); - - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Lookup failed."); - } - - const url = data.interactionTemplate.replace( - "{uri}", - encodeURIComponent(apUrl), - ); - setInteractionUrl(url); - setDomain(data.domain); - } catch (err) { - setError(err instanceof Error ? err.message : "Lookup failed."); - } finally { - setLoading(false); - } - }; - - const handleVote = () => { - if (!interactionUrl) return; - window.open(interactionUrl, "_blank", "noopener,noreferrer"); - setOpen(false); - }; - - const handleOpenChange = (next: boolean) => { - setOpen(next); - if (!next) { - setHandle(""); - setError(""); - setInteractionUrl(null); - setDomain(null); - setLoading(false); - } - }; - return ( - - - - - - - Remote Vote - - Vote on this poll from your fediverse instance. - - - - {!interactionUrl ? ( -
-
- - { - setHandle(e.target.value); - if (error) setError(""); - }} - disabled={loading} - /> - {error &&

{error}

} -
-
- - -
-
- ) : ( -
-

- You will be redirected to {domain} to find and - vote on this poll. -

- {error &&

{error}

} -
- - -
-
- )} -
-
+ ); } diff --git a/src/hooks/useRemoteInstance.ts b/src/hooks/useRemoteInstance.ts new file mode 100644 index 0000000..fcf780e --- /dev/null +++ b/src/hooks/useRemoteInstance.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + type CachedInstance, + getStore, + rememberInstance, + removeInstance, + setDefaultInstance, +} from "~/lib/remote-instance"; + +/** + * Domain of the user's primary linked fediverse account, fetched at most once + * per page load and shared across every remote-interaction button on the page. + */ +let primaryLinkedDomain: Promise | null = null; + +function getPrimaryLinkedDomain(): Promise { + primaryLinkedDomain ??= fetch("/api/auth/linked-accounts") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const accounts: Array<{ fediverseHandle?: string; isPrimary?: boolean }> = + data?.accounts ?? []; + const primary = accounts.find((a) => a.isPrimary) ?? accounts[0]; + const domain = primary?.fediverseHandle?.split("@").pop(); + return domain ? domain.toLowerCase() : null; + }) + .catch(() => null); // anonymous or offline — localStorage only + return primaryLinkedDomain; +} + +/** + * Surfaces the user's remembered fediverse instances for remote interactions. + * + * The default instance is, in priority order: an explicitly chosen one, the + * domain of the user's primary linked fediverse account (logged-in users), or + * the most recently used instance. + */ +export function useRemoteInstance() { + const [store, setStore] = useState(getStore); + const [linkedDomain, setLinkedDomain] = useState(null); + + // Derive an implicit default from the primary linked fediverse account. + useEffect(() => { + let cancelled = false; + getPrimaryLinkedDomain().then((domain) => { + if (!cancelled && domain) setLinkedDomain(domain); + }); + return () => { + cancelled = true; + }; + }, []); + + const knownInstances = useMemo(() => { + const list = [...store.instances]; + if (linkedDomain && !list.some((i) => i.domain === linkedDomain)) { + list.push({ + domain: linkedDomain, + title: linkedDomain, + software: null, + lastUsedAt: 0, + }); + } + return list; + }, [store.instances, linkedDomain]); + + const defaultInstance = + store.defaultInstance ?? linkedDomain ?? store.instances[0]?.domain ?? null; + + const remember = useCallback( + (instance: Pick) => { + setStore(rememberInstance(instance)); + }, + [], + ); + + const setDefault = useCallback((domain: string | null) => { + setStore(setDefaultInstance(domain)); + }, []); + + const remove = useCallback((domain: string) => { + setStore(removeInstance(domain)); + }, []); + + return { defaultInstance, knownInstances, remember, setDefault, remove }; +} diff --git a/src/lib/remote-instance.ts b/src/lib/remote-instance.ts new file mode 100644 index 0000000..ddc67af --- /dev/null +++ b/src/lib/remote-instance.ts @@ -0,0 +1,75 @@ +/** + * Browser-side memory of fediverse instances the user has interacted from. + * + * Remote follow / vote / reply all send the user to their *home* instance. By + * remembering which instance that is, repeat interactions become a single + * click instead of re-typing a handle every time. + */ + +export interface CachedInstance { + domain: string; + title: string; + software: string | null; + lastUsedAt: number; +} + +interface RemoteInstanceStore { + /** Domain the user explicitly prefers, if any. */ + defaultInstance: string | null; + instances: CachedInstance[]; +} + +const STORAGE_KEY = "moim:remote-instance"; +const EMPTY: RemoteInstanceStore = { defaultInstance: null, instances: [] }; + +export function getStore(): RemoteInstanceStore { + if (typeof window === "undefined") return EMPTY; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return EMPTY; + const parsed = JSON.parse(raw) as RemoteInstanceStore; + return { + defaultInstance: parsed.defaultInstance ?? null, + instances: Array.isArray(parsed.instances) ? parsed.instances : [], + }; + } catch { + return EMPTY; + } +} + +function writeStore(store: RemoteInstanceStore): RemoteInstanceStore { + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + /* storage unavailable (private mode / quota) — degrade silently */ + } + } + return store; +} + +/** Records (or refreshes) an instance and marks it most-recently-used. */ +export function rememberInstance( + instance: Pick, +): RemoteInstanceStore { + const store = getStore(); + const others = store.instances.filter((i) => i.domain !== instance.domain); + return writeStore({ + // First instance the user ever uses becomes the implicit default. + defaultInstance: store.defaultInstance ?? instance.domain, + instances: [{ ...instance, lastUsedAt: Date.now() }, ...others], + }); +} + +export function setDefaultInstance(domain: string | null): RemoteInstanceStore { + return writeStore({ ...getStore(), defaultInstance: domain }); +} + +export function removeInstance(domain: string): RemoteInstanceStore { + const store = getStore(); + return writeStore({ + defaultInstance: + store.defaultInstance === domain ? null : store.defaultInstance, + instances: store.instances.filter((i) => i.domain !== domain), + }); +} diff --git a/src/server-entry.ts b/src/server-entry.ts index 781208f..6ade265 100644 --- a/src/server-entry.ts +++ b/src/server-entry.ts @@ -74,8 +74,7 @@ import { GET as getCarouselSlides } from "./server/controllers/carousel"; import { GET as getMapConfig } from "./server/controllers/map-config/get"; import { env } from "./server/env"; import { POST as trackBannerClick } from "./server/controllers/banner-click"; -import { POST as webfingerLookup } from "./server/controllers/api/webfinger"; -import { POST as instanceLookup } from "./server/controllers/api/instance-lookup"; +import { POST as resolveInstance } from "./server/controllers/api/resolve-instance"; import { GET as groupFeed } from "./server/controllers/groups/feed"; import { GET as eventDashboard } from "./server/controllers/events/dashboard"; import { GET as eventDashboardActivity } from "./server/controllers/events/dashboard-activity"; @@ -979,12 +978,8 @@ apiRouter.post("/banner-clicks", defineEventHandler(async (event) => { return trackBannerClick({ request: toWebRequest(event) }); })); -apiRouter.post("/webfinger", defineEventHandler(async (event) => { - return webfingerLookup({ request: toWebRequest(event) }); -})); - -apiRouter.post("/instance-lookup", defineEventHandler(async (event) => { - return instanceLookup({ request: toWebRequest(event) }); +apiRouter.post("/resolve-instance", defineEventHandler(async (event) => { + return resolveInstance({ request: toWebRequest(event) }); })); app.use("/api", useBase("/api", apiRouter.handler)); diff --git a/src/server/controllers/api/instance-lookup.ts b/src/server/controllers/api/instance-lookup.ts deleted file mode 100644 index 06119ea..0000000 --- a/src/server/controllers/api/instance-lookup.ts +++ /dev/null @@ -1,71 +0,0 @@ -interface WebFingerLink { - rel?: string; - type?: string; - href?: string; - template?: string; -} - -interface WebFingerResponse { - links?: WebFingerLink[]; -} - -export async function POST({ request }: { request: Request }) { - const body = await request.json(); - const { handle } = body as { handle: string }; - - if (!handle || typeof handle !== "string") { - return Response.json( - { error: "Fediverse handle is required." }, - { status: 400 }, - ); - } - - const match = handle.trim().match(/^@?([^@]+)@([^@]+)$/); - if (!match) { - return Response.json( - { error: "Invalid fediverse handle format." }, - { status: 400 }, - ); - } - - const [, username, domain] = match; - const normalizedId = `${username}@${domain}`; - - try { - const webfingerUrl = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${normalizedId}`)}`; - const wfResponse = await fetch(webfingerUrl, { - headers: { Accept: "application/jrd+json, application/json" }, - }); - - if (!wfResponse.ok) { - return Response.json( - { error: `Instance lookup failed: ${wfResponse.status}` }, - { status: 404 }, - ); - } - - const wfData = (await wfResponse.json()) as WebFingerResponse; - - // Priority 1: FEP-3b86 Activity Intents - const fep3b86Link = wfData.links?.find( - (link) => link.rel === "https://w3id.org/fep/3b86/Object", - ); - - // Priority 2: OStatus subscribe template - const subscribeLink = wfData.links?.find( - (link) => link.rel === "http://ostatus.org/schema/1.0/subscribe", - ); - - const interactionTemplate = - fep3b86Link?.template ?? - subscribeLink?.template ?? - `https://${domain}/search?query={uri}`; - - return Response.json({ domain, interactionTemplate }); - } catch { - return Response.json( - { error: "Instance lookup failed." }, - { status: 500 }, - ); - } -} diff --git a/src/server/controllers/api/resolve-instance-domain.ts b/src/server/controllers/api/resolve-instance-domain.ts new file mode 100644 index 0000000..126825a --- /dev/null +++ b/src/server/controllers/api/resolve-instance-domain.ts @@ -0,0 +1,59 @@ +/** + * Pure helpers for resolving a fediverse instance into a remote interaction + * URL. Kept dependency-free so it can be unit tested in isolation (mirrors the + * `ticketing` / `ticketing-domain` split used elsewhere in the codebase). + */ + +/** Known interaction URL patterns keyed by NodeInfo `software.name`. */ +const SOFTWARE_TEMPLATES: Record = { + mastodon: "https://{domain}/authorize_interaction?uri={uri}", + hometown: "https://{domain}/authorize_interaction?uri={uri}", + gotosocial: "https://{domain}/authorize_interaction?uri={uri}", + pleroma: "https://{domain}/ostatus_subscribe?acct={uri}", + akkoma: "https://{domain}/ostatus_subscribe?acct={uri}", + misskey: "https://{domain}/authorize-follow?acct={uri}", + firefish: "https://{domain}/authorize-follow?acct={uri}", + iceshrimp: "https://{domain}/authorize-follow?acct={uri}", + sharkey: "https://{domain}/authorize-follow?acct={uri}", + cherrypick: "https://{domain}/authorize-follow?acct={uri}", +}; + +/** Used when the software is unknown — Mastodon's pattern is the most common. */ +const FALLBACK_TEMPLATE = "https://{domain}/authorize_interaction?uri={uri}"; + +/** + * Normalizes user input (a bare domain, a `@user@domain` handle, or a URL) to a + * lowercase domain. Returns `null` for inputs that fail a basic SSRF guard + * (IP literals, localhost, non-public TLDs). + */ +export function normalizeDomain(input: string): string | null { + let value = input.trim().toLowerCase(); + if (!value) return null; + + // Strip protocol and any path. + value = value.replace(/^https?:\/\//, "").split("/")[0]; + // `@user@domain` or `user@domain` -> take the last segment. + value = value.replace(/^@/, ""); + if (value.includes("@")) value = value.split("@").pop() ?? ""; + // Drop any port. + value = value.split(":")[0]; + + if (!value || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(value)) return null; + // Reject loopback / internal-only names. + if ( + value === "localhost" || + value.endsWith(".localhost") || + value.endsWith(".local") || + value.endsWith(".internal") || + /^\d+\.\d+\.\d+\.\d+$/.test(value) + ) { + return null; + } + return value; +} + +/** Picks the interaction URL template for a detected software name. */ +export function templateForSoftware(software: string | null): string { + if (!software) return FALLBACK_TEMPLATE; + return SOFTWARE_TEMPLATES[software.toLowerCase()] ?? FALLBACK_TEMPLATE; +} diff --git a/src/server/controllers/api/resolve-instance.test.ts b/src/server/controllers/api/resolve-instance.test.ts new file mode 100644 index 0000000..fe4657f --- /dev/null +++ b/src/server/controllers/api/resolve-instance.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDomain, templateForSoftware } from "./resolve-instance-domain"; + +describe("normalizeDomain", () => { + it("accepts a bare domain", () => { + expect(normalizeDomain("mastodon.social")).toBe("mastodon.social"); + }); + + it("extracts the domain from a @user@domain handle", () => { + expect(normalizeDomain("@alice@misskey.io")).toBe("misskey.io"); + expect(normalizeDomain("bob@pleroma.example.org")).toBe( + "pleroma.example.org", + ); + }); + + it("strips protocol, path and port", () => { + expect(normalizeDomain("https://Mastodon.Social/about")).toBe( + "mastodon.social", + ); + expect(normalizeDomain("mastodon.social:443")).toBe("mastodon.social"); + }); + + it("rejects loopback and internal-only names (SSRF guard)", () => { + expect(normalizeDomain("localhost")).toBeNull(); + expect(normalizeDomain("router.local")).toBeNull(); + expect(normalizeDomain("svc.internal")).toBeNull(); + expect(normalizeDomain("127.0.0.1")).toBeNull(); + expect(normalizeDomain("192.168.1.1")).toBeNull(); + }); + + it("rejects empty or malformed input", () => { + expect(normalizeDomain("")).toBeNull(); + expect(normalizeDomain("not a domain")).toBeNull(); + expect(normalizeDomain("nodot")).toBeNull(); + }); +}); + +describe("templateForSoftware", () => { + it("maps known software to its interaction URL pattern", () => { + expect(templateForSoftware("mastodon")).toContain("authorize_interaction"); + expect(templateForSoftware("Misskey")).toContain("authorize-follow"); + expect(templateForSoftware("akkoma")).toContain("ostatus_subscribe"); + }); + + it("falls back to the Mastodon pattern for unknown or missing software", () => { + expect(templateForSoftware(null)).toContain("authorize_interaction"); + expect(templateForSoftware("somenewthing")).toContain( + "authorize_interaction", + ); + }); +}); diff --git a/src/server/controllers/api/resolve-instance.ts b/src/server/controllers/api/resolve-instance.ts new file mode 100644 index 0000000..f24d0d8 --- /dev/null +++ b/src/server/controllers/api/resolve-instance.ts @@ -0,0 +1,131 @@ +import { env } from "~/server/env"; +import { normalizeDomain, templateForSoftware } from "./resolve-instance-domain"; + +/** + * Resolves a fediverse instance into a ready-to-open remote interaction URL + * (remote follow / remote vote / remote reply). + * + * Unlike a per-account WebFinger lookup, the OStatus `subscribe` template is + * instance-wide, so the caller only needs to supply an instance domain. When a + * full `user@domain` handle is given we still use the canonical per-account + * WebFinger template; otherwise we detect the server software via NodeInfo and + * fall back to its known interaction URL pattern. + */ + +interface WebFingerLink { + rel?: string; + template?: string; + href?: string; +} + +interface NodeInfoDoc { + software?: { name?: string }; + metadata?: { nodeName?: string }; +} + +const FETCH_TIMEOUT_MS = 5000; + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { + headers: { Accept: "application/jrd+json, application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +/** Fetches `software.name` and `metadata.nodeName` (the instance title) via NodeInfo. */ +async function fetchNodeInfo( + domain: string, +): Promise<{ software: string | null; title: string | null }> { + const index = await fetchJson<{ links?: WebFingerLink[] }>( + `https://${domain}/.well-known/nodeinfo`, + ); + const href = + index?.links?.find((l) => l.rel?.endsWith("/2.1"))?.href ?? + index?.links?.find((l) => l.rel?.endsWith("/2.0"))?.href ?? + index?.links?.[0]?.href; + if (!href) return { software: null, title: null }; + + const doc = await fetchJson(href); + return { + software: doc?.software?.name?.toLowerCase() ?? null, + title: doc?.metadata?.nodeName ?? null, + }; +} + +/** Canonical per-account lookup: returns the FEP-3b86 / OStatus subscribe template. */ +async function fetchWebFingerTemplate(handle: string): Promise { + const match = handle.trim().replace(/^@/, "").match(/^([^@]+)@([^@]+)$/); + if (!match) return null; + const [, username, domain] = match; + const wf = await fetchJson<{ links?: WebFingerLink[] }>( + `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${username}@${domain}`)}`, + ); + // Priority 1: FEP-3b86 Activity Intents. Priority 2: OStatus subscribe. + return ( + wf?.links?.find((l) => l.rel === "https://w3id.org/fep/3b86/Object") + ?.template ?? + wf?.links?.find((l) => l.rel === "http://ostatus.org/schema/1.0/subscribe") + ?.template ?? + null + ); +} + +export async function POST({ request }: { request: Request }) { + const body = (await request.json().catch(() => null)) as { + instance?: string; + target?: { type?: "handle" | "url"; value?: string }; + } | null; + + const rawInstance = body?.instance; + const target = body?.target; + if (!rawInstance || typeof rawInstance !== "string") { + return Response.json({ error: "An instance is required." }, { status: 400 }); + } + if (!target?.value || (target.type !== "handle" && target.type !== "url")) { + return Response.json({ error: "A valid target is required." }, { status: 400 }); + } + + const domain = normalizeDomain(rawInstance); + if (!domain) { + return Response.json( + { error: "Enter a valid instance domain, e.g. mastodon.social" }, + { status: 400 }, + ); + } + + // The {uri} the remote instance will act on. + const targetUri = + target.type === "handle" + ? `${target.value.replace(/^@/, "")}@${env.federationHandleDomain}` + : target.value; + + try { + const { software, title } = await fetchNodeInfo(domain); + + // Prefer the canonical WebFinger template when a full handle was supplied. + let template: string | null = null; + if (rawInstance.trim().replace(/^@/, "").includes("@")) { + template = await fetchWebFingerTemplate(rawInstance); + } + template ??= templateForSoftware(software); + + const interactionUrl = template + .replace("{domain}", domain) + .replace("{uri}", encodeURIComponent(targetUri)); + + return Response.json({ + domain, + title: title ?? domain, + software, + interactionUrl, + }); + } catch { + return Response.json({ error: "Instance lookup failed." }, { status: 500 }); + } +} diff --git a/src/server/controllers/api/webfinger.ts b/src/server/controllers/api/webfinger.ts deleted file mode 100644 index e2fd8c3..0000000 --- a/src/server/controllers/api/webfinger.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { isActor } from "@fedify/fedify"; -import { getFederationContext } from "~/server/fediverse/federation"; -import { env } from "~/server/env"; - -interface WebFingerLink { - rel?: string; - type?: string; - href?: string; - template?: string; -} - -interface WebFingerResponse { - links?: WebFingerLink[]; -} - -export async function POST({ request }: { request: Request }) { - const body = await request.json(); - const { fediverseId, actorHandle } = body as { - fediverseId: string; - actorHandle: string; - }; - - if (!fediverseId || typeof fediverseId !== "string") { - return Response.json({ error: "Fediverse handle is required." }, { status: 400 }); - } - - const match = fediverseId.trim().match(/^@?([^@]+)@([^@]+)$/); - if (!match) { - return Response.json({ error: "Invalid fediverse handle format." }, { status: 400 }); - } - - const [, username, domain] = match; - const normalizedId = `${username}@${domain}`; - - try { - // WebFinger lookup on the remote instance - const webfingerUrl = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(`acct:${normalizedId}`)}`; - const wfResponse = await fetch(webfingerUrl, { - headers: { Accept: "application/jrd+json, application/json" }, - }); - if (!wfResponse.ok) { - return Response.json({ error: `WebFinger lookup failed: ${wfResponse.status}` }, { status: 404 }); - } - - const wfData = (await wfResponse.json()) as WebFingerResponse; - - // Find the ActivityPub self link - const apLink = wfData.links?.find( - (link) => link.rel === "self" && link.type === "application/activity+json", - ); - - // Find the subscribe template - const subscribeLink = wfData.links?.find( - (link) => link.rel === "http://ostatus.org/schema/1.0/subscribe", - ); - - // Build remote follow URL using the subscribe template - const fullActorHandle = `${actorHandle}@${env.federationHandleDomain}`; - const remoteFollowUrl = subscribeLink?.template?.replace( - "{uri}", - encodeURIComponent(fullActorHandle), - ); - - // Try to fetch actor profile for display info - let actorInfo: { - name?: string; - handle: string; - icon?: string; - summary?: string; - remoteFollowUrl?: string; - } = { - handle: normalizedId, - remoteFollowUrl, - }; - - if (apLink?.href) { - try { - const ctx = getFederationContext(); - const actorObject = await ctx.lookupObject(apLink.href); - - if (actorObject && isActor(actorObject)) { - const icon = await actorObject.getIcon(); - let iconUrl: string | undefined; - if (icon?.url) { - iconUrl = icon.url instanceof URL ? icon.url.href : icon.url?.href?.href; - } - if (!iconUrl && actorObject.iconId) { - iconUrl = actorObject.iconId.href; - } - - actorInfo = { - name: actorObject.name?.toString(), - handle: normalizedId, - icon: iconUrl, - summary: actorObject.summary?.toString(), - remoteFollowUrl, - }; - } - } catch { - // Fallback: use basic info from WebFinger - } - } - - return Response.json({ actor: actorInfo }); - } catch { - return Response.json({ error: "WebFinger lookup failed." }, { status: 500 }); - } -}