From 8c38876ea458f86f26e5c0fcc9c8339f4fdb2901 Mon Sep 17 00:00:00 2001 From: Tom Sherman Date: Fri, 1 Nov 2024 19:27:40 +0000 Subject: [PATCH] Detect AT URI from http and html alternate links (#190) * Detect AT URI from http and html alternate links * Add comment * Add a uri decode * Use a proper link parser * Abort the fetch, add user agent * Factor out into shared function --- packages/atproto-browser/app/actions.ts | 30 ++---- packages/atproto-browser/app/at/route.ts | 18 ++-- packages/atproto-browser/app/aturi-form.tsx | 7 +- packages/atproto-browser/lib/navigation.ts | 101 ++++++++++++++++++++ packages/atproto-browser/package.json | 3 + pnpm-lock.yaml | 96 +++++++++++++++++++ 6 files changed, 222 insertions(+), 33 deletions(-) create mode 100644 packages/atproto-browser/lib/navigation.ts diff --git a/packages/atproto-browser/app/actions.ts b/packages/atproto-browser/app/actions.ts index 7e88c135..4c035474 100644 --- a/packages/atproto-browser/app/actions.ts +++ b/packages/atproto-browser/app/actions.ts @@ -1,32 +1,14 @@ "use server"; -import { getAtUriPath } from "@/lib/util"; -import { AtUri, isValidHandle } from "@atproto/syntax"; -import { redirect } from "next/navigation"; +import { navigateAtUri } from "@/lib/navigation"; -export async function navigateUri(_state: unknown, formData: FormData) { +export async function navigateUriAction(_state: unknown, formData: FormData) { const uriInput = formData.get("uri") as string; - const handle = parseHandle(uriInput); - if (handle) { - redirect(getAtUriPath(new AtUri(`at://${handle}`))); - } + const result = await navigateAtUri(uriInput); - let uri; - try { - uri = new AtUri(uriInput); - } catch (_) { - return { - error: `Invalid URI: ${uriInput}`, - }; + if ("error" in result) { + return result; } - - redirect(getAtUriPath(uri)); -} - -function parseHandle(input: string): string | null { - if (!input.startsWith("@")) return null; - const handle = input.slice(1); - if (!isValidHandle(handle)) return null; - return handle; + throw new Error("Should have redirected"); } diff --git a/packages/atproto-browser/app/at/route.ts b/packages/atproto-browser/app/at/route.ts index b01fe6a9..613ac619 100644 --- a/packages/atproto-browser/app/at/route.ts +++ b/packages/atproto-browser/app/at/route.ts @@ -1,10 +1,14 @@ -import { getAtUriPath } from "@/lib/util"; -import { AtUri } from "@atproto/syntax"; -import { redirect } from "next/navigation"; +import { navigateAtUri } from "@/lib/navigation"; -export function GET(request: Request) { +export async function GET(request: Request) { const searchParams = new URL(request.url).searchParams; - console.log(searchParams.get("u")); - const uri = new AtUri(searchParams.get("u")!); - redirect(getAtUriPath(uri)); + const u = searchParams.get("u"); + if (!u) { + return new Response("Missing u parameter", { status: 400 }); + } + const result = await navigateAtUri(u); + if ("error" in result) { + return new Response(result.error, { status: 400 }); + } + throw new Error("Should have redirected"); } diff --git a/packages/atproto-browser/app/aturi-form.tsx b/packages/atproto-browser/app/aturi-form.tsx index 90d1df88..fa8cef8d 100644 --- a/packages/atproto-browser/app/aturi-form.tsx +++ b/packages/atproto-browser/app/aturi-form.tsx @@ -1,6 +1,6 @@ "use client"; import { useActionState } from "react"; -import { navigateUri } from "./actions"; +import { navigateUriAction } from "./actions"; export function AtUriForm({ defaultUri, @@ -9,7 +9,10 @@ export function AtUriForm({ defaultUri?: string; style?: React.CSSProperties; }) { - const [state, action, isPending] = useActionState(navigateUri, undefined); + const [state, action, isPending] = useActionState( + navigateUriAction, + undefined, + ); return (
diff --git a/packages/atproto-browser/lib/navigation.ts b/packages/atproto-browser/lib/navigation.ts new file mode 100644 index 00000000..f7ab077a --- /dev/null +++ b/packages/atproto-browser/lib/navigation.ts @@ -0,0 +1,101 @@ +import "server-only"; + +import { getAtUriPath } from "./util"; +import { AtUri, isValidHandle } from "@atproto/syntax"; +import { redirect } from "next/navigation"; +import { parse as parseHtml } from "node-html-parser"; +import { parse as parseLinkHeader } from "http-link-header"; + +export async function navigateAtUri(input: string) { + const handle = parseHandle(input); + + if (handle) { + redirect(getAtUriPath(new AtUri(`at://${handle}`))); + } + + const result = + input.startsWith("http://") || input.startsWith("https://") + ? await getAtUriFromHttp(input) + : parseUri(input); + + if ("error" in result) { + return result; + } + + redirect(getAtUriPath(result.uri)); +} + +type UriParseResult = + | { + error: string; + } + | { uri: AtUri }; + +async function getAtUriFromHttp(url: string): Promise { + const controller = new AbortController(); + const response = await fetch(url, { + headers: { + "User-Agent": "atproto-browser.vercel.app", + }, + signal: controller.signal, + }); + if (!response.ok) { + controller.abort(); + return { error: `Failed to fetch ${url}` }; + } + + const linkHeader = response.headers.get("Link"); + if (linkHeader) { + const ref = parseLinkHeader(linkHeader).refs.find( + (ref) => ref.rel === "alternate" && ref.uri.startsWith("at://"), + ); + const result = ref ? parseUri(ref.uri) : null; + if (result && "uri" in result) { + controller.abort(); + redirect(getAtUriPath(result.uri)); + } + } + + const html = await response.text(); + let doc; + try { + doc = parseHtml(html); + } catch (_) { + return { + error: `Failed to parse HTML from ${url}`, + }; + } + + const alternates = doc.querySelectorAll('link[rel="alternate"]'); + // Choose the first AT URI found in the alternates, there's not really a better way to choose the right one + const atUriAlternate = alternates.find((link) => + link.getAttribute("href")?.startsWith("at://"), + ); + if (atUriAlternate) { + const result = parseUri(atUriAlternate.getAttribute("href")!); + if ("uri" in result) { + return result; + } + } + + return { + error: `No AT URI found in ${url}`, + }; +} + +function parseUri(input: string): UriParseResult { + try { + return { uri: new AtUri(input) }; + } catch (_) { + return { + error: `Invalid URI: ${input}`, + }; + } +} + +function parseHandle(input: string): string | null { + if (!input.startsWith("@")) return null; + const handle = input.slice(1); + if (!isValidHandle(handle)) return null; + return handle; +} diff --git a/packages/atproto-browser/package.json b/packages/atproto-browser/package.json index 2abca09d..fd0b35e7 100644 --- a/packages/atproto-browser/package.json +++ b/packages/atproto-browser/package.json @@ -15,7 +15,9 @@ "@atproto/repo": "^0.4.3", "@atproto/syntax": "^0.3.0", "hls.js": "^1.5.15", + "http-link-header": "^1.1.3", "next": "15.0.0-rc.0", + "node-html-parser": "^6.1.13", "parse-hls": "^1.0.7", "react": "19.0.0-rc-f994737d14-20240522", "react-dom": "19.0.0-rc-f994737d14-20240522", @@ -27,6 +29,7 @@ "devDependencies": { "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", + "@types/http-link-header": "^1.0.7", "@types/node": "^20", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c6f49c8..499113a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,15 @@ importers: hls.js: specifier: ^1.5.15 version: 1.5.15 + http-link-header: + specifier: ^1.1.3 + version: 1.1.3 next: specifier: 15.0.0-rc.0 version: 15.0.0-rc.0(@opentelemetry/api@1.9.0)(react-dom@19.0.0-rc-f994737d14-20240522(react@19.0.0-rc-f994737d14-20240522))(react@19.0.0-rc-f994737d14-20240522) + node-html-parser: + specifier: ^6.1.13 + version: 6.1.13 parse-hls: specifier: ^1.0.7 version: 1.0.7 @@ -63,6 +69,9 @@ importers: '@repo/typescript-config': specifier: workspace:* version: link:../typescript-config + '@types/http-link-header': + specifier: ^1.0.7 + version: 1.0.7 '@types/node': specifier: ^20 version: 20.13.0 @@ -2478,6 +2487,9 @@ packages: '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/http-link-header@1.0.7': + resolution: {integrity: sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2985,6 +2997,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -3161,6 +3176,13 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3290,6 +3312,19 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -4004,6 +4039,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + hermes-estree@0.20.1: resolution: {integrity: sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==} @@ -4023,6 +4062,10 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + http-link-header@1.1.3: + resolution: {integrity: sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==} + engines: {node: '>=6.0.0'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -4569,6 +4612,9 @@ packages: resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} hasBin: true + node-html-parser@6.1.13: + resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} + node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} @@ -4583,6 +4629,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.12: resolution: {integrity: sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==} @@ -7932,6 +7981,10 @@ snapshots: '@types/estree@1.0.5': {} + '@types/http-link-header@1.0.7': + dependencies: + '@types/node': 20.13.0 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -8672,6 +8725,8 @@ snapshots: readable-stream: 3.6.2 optional: true + boolbase@1.0.0: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -8863,6 +8918,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-what@6.1.0: {} + cssesc@3.0.0: {} cssstyle@4.0.1: @@ -8967,6 +9032,24 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.0.3: {} dotenv@16.4.5: {} @@ -10022,6 +10105,8 @@ snapshots: dependencies: function-bind: 1.1.2 + he@1.2.0: {} + hermes-estree@0.20.1: {} hermes-parser@0.20.1: @@ -10040,6 +10125,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + http-link-header@1.1.3: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 @@ -10606,6 +10693,11 @@ snapshots: node-gyp-build@4.8.1: optional: true + node-html-parser@6.1.13: + dependencies: + css-select: 5.1.0 + he: 1.2.0 + node-releases@2.0.14: {} normalize-package-data@2.5.0: @@ -10621,6 +10713,10 @@ snapshots: dependencies: path-key: 4.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + nwsapi@2.2.12: {} oauth4webapi@2.12.1: {}