Skip to content

Commit

Permalink
Detect AT URI from http and html alternate links (#190)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tom-sherman authored Nov 1, 2024
1 parent d1afd44 commit 8c38876
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 33 deletions.
30 changes: 6 additions & 24 deletions packages/atproto-browser/app/actions.ts
Original file line number Diff line number Diff line change
@@ -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");
}
18 changes: 11 additions & 7 deletions packages/atproto-browser/app/at/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
7 changes: 5 additions & 2 deletions packages/atproto-browser/app/aturi-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";
import { useActionState } from "react";
import { navigateUri } from "./actions";
import { navigateUriAction } from "./actions";

export function AtUriForm({
defaultUri,
Expand All @@ -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 (
<div style={style}>
<form action={action} style={{ display: "flex" }}>
Expand Down
101 changes: 101 additions & 0 deletions packages/atproto-browser/lib/navigation.ts
Original file line number Diff line number Diff line change
@@ -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<UriParseResult> {
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;
}
3 changes: 3 additions & 0 deletions packages/atproto-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading

0 comments on commit 8c38876

Please sign in to comment.