import { NextRequest, NextResponse } from 'next/server';
import {
matchSupportedUrl,
parseAtUri,
isSupportedHost,
type ReverseMatch,
} from '@/utils/reverseParsers';
import {
WAYPOINT_DESTINATIONS_DATA,
WAYPOINT_ORDER,
getRecommendedWaypointsData,
type WaypointType,
} from '@/utils/waypoints.data';
import { resolveHandle } from '@/utils/uriParser';
import { isBlockedFetchHost } from '@/utils/ssrfGuard';
export const runtime = 'edge';
/**
* Resolves a page URL (from a share sheet, an Apple Shortcut, etc.) into the
* AT URI it represents and the list of Aturi waypoints that can render it.
*
* Mirrors the in-popup logic of the browser extension so that a single call
* gives a client (Shortcut, bookmarklet, third-party app) everything it needs
* to present a "open in..." picker without re-implementing the catalog.
*
* Detection has two phases, in order:
* 1. URL-pattern matching via `matchSupportedUrl` (covers bsky.app, leaflet,
* pdsls, atp.tools, the Bluesky-fork family, and friends).
* 2. Head-link probing: fetch the page (capped to ~256KB and a short timeout)
* and look for `` in the document head. Optional;
* callers can suppress with `?headDetect=false`.
*
* Inputs:
* - `url=` (preferred from share sheets)
* - `atUri=at://...` (skips detection entirely)
*
* Either is accepted; `atUri` wins when both are supplied.
*/
const DID_REQUIRED_WAYPOINTS = new Set([
'pdsls',
'atptools',
'margin',
'grain',
'popfeed',
]);
const HEAD_FETCH_TIMEOUT_MS = 4000;
// Stop reading well before most pages finish. The head almost always lives in
// the first ~64KB; 256KB is generous overhead for sites that ship enormous
// inline JSON/JS before .
const HEAD_FETCH_MAX_BYTES = 256 * 1024;
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'content-type',
};
export async function OPTIONS() {
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const rawAtUri = searchParams.get('atUri') || searchParams.get('aturi');
const rawUrl = searchParams.get('url');
const skipHead = searchParams.get('headDetect') === 'false';
if (!rawAtUri && !rawUrl) {
return jsonError(400, 'Missing url or atUri parameter');
}
let match: ReverseMatch | null = null;
let isKnownHost = false;
let inputKind: 'atUri' | 'url' = 'url';
let detectedVia: 'atUri' | 'urlPattern' | 'headLink' | null = null;
if (rawAtUri) {
inputKind = 'atUri';
match = parseAtUri(rawAtUri.trim());
if (!match) return jsonError(400, 'Invalid atUri');
detectedVia = 'atUri';
} else if (rawUrl) {
let parsedUrl: URL;
try {
parsedUrl = new URL(rawUrl);
} catch {
return jsonError(400, 'Invalid url');
}
if (!/^https?:$/.test(parsedUrl.protocol)) {
return jsonError(400, 'Only http(s) URLs are supported');
}
isKnownHost = isSupportedHost(parsedUrl.hostname);
match = matchSupportedUrl(parsedUrl);
if (match) {
detectedVia = 'urlPattern';
} else if (!skipHead && !isBlockedFetchHost(parsedUrl.hostname)) {
// Only fetch the page for head-link detection when it's a public host;
// never let this endpoint probe loopback/private/internal addresses.
const headAtUri = await detectAtUriInHead(parsedUrl.toString());
if (headAtUri) {
match = parseAtUri(headAtUri);
if (match) detectedVia = 'headLink';
}
}
}
if (!match) {
return NextResponse.json(
{
ok: false,
input: rawAtUri ?? rawUrl,
inputKind,
isKnownHost,
reason: 'no-atmosphere-data',
message:
"Couldn't find a supported AT URI for this page (no URL pattern match and no in ).",
},
{ status: 200, headers: corsAndCache(60) }
);
}
const { source, parsed } = match;
// Resolve handle -> DID when we don't already have one. Required for the
// DID-only destinations (pdsls, atptools, margin, grain, popfeed); harmless
// for the rest.
let did = parsed.did;
let didResolved = false;
if (!did) {
try {
const resolved = await resolveHandle(parsed.handle);
if (resolved) {
did = resolved;
didResolved = true;
}
} catch {
// Swallow; DID-required waypoints will simply be omitted from the result.
}
}
const type: WaypointType = parsed.type === 'unknown' ? 'profile' : parsed.type;
const waypoints = WAYPOINT_ORDER
.map(id => WAYPOINT_DESTINATIONS_DATA[id])
.filter((w): w is NonNullable => !!w)
// Skip the page the user is already on, mirroring the extension popup.
// For head-detected matches `source === 'headDetected'`, which won't match
// any real waypoint id so this is a no-op there.
.filter(w => w.id !== source)
.filter(w => w.supportedTypes.includes(type))
.map(w => {
const needsDid = DID_REQUIRED_WAYPOINTS.has(w.id);
if (needsDid && !did) return null;
const url = w.getUrl(parsed.handle, parsed.collection, parsed.rkey, did);
if (!url) return null;
return {
id: w.id,
name: w.name,
category: w.category,
url,
};
})
.filter((w): w is { id: string; name: string; category: string; url: string } => !!w);
const recommendedRaw = getRecommendedWaypointsData(type, parsed.collection);
const availableIds = new Set(waypoints.map(w => w.id));
const recommendedIds = recommendedRaw.waypoints
.map(w => w.id)
.filter(id => id !== source && availableIds.has(id));
return NextResponse.json(
{
ok: true,
inputKind,
detectedVia,
source,
isKnownHost,
parsed: {
type,
uri: parsed.uri,
handle: parsed.handle,
did: did ?? null,
collection: parsed.collection ?? null,
rkey: parsed.rkey ?? null,
},
didResolved,
recommended: { ids: recommendedIds, label: recommendedRaw.label },
waypoints,
},
{ status: 200, headers: corsAndCache(300) }
);
}
function corsAndCache(seconds: number) {
return {
...CORS_HEADERS,
'Cache-Control': `public, max-age=${seconds}, s-maxage=${seconds}, stale-while-revalidate=${seconds * 6}`,
'Content-Type': 'application/json; charset=utf-8',
};
}
function jsonError(status: number, message: string) {
return NextResponse.json(
{ ok: false, error: message },
{ status, headers: CORS_HEADERS }
);
}
/**
* Stream-fetch the page and look for `` in the document
* head. Bails out as soon as `` is seen or we cross the byte cap, so
* the worst case is bounded even on very large pages.
*
* Returns the first AT URI found, or null if none/unreadable/timeout.
*/
async function detectAtUriInHead(url: string): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HEAD_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
headers: {
// Some atmosphere apps (Leaflet, Offprint, pckt) gate on UA; identify
// ourselves clearly and request HTML.
'User-Agent':
'Mozilla/5.0 (compatible; AturiResolver/1.0; +https://aturi.to)',
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.5',
},
});
if (!response.ok || !response.body) return null;
const ct = response.headers.get('content-type') || '';
if (!/text\/html|application\/xhtml/i.test(ct)) return null;
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let bytesRead = 0;
try {
while (bytesRead < HEAD_FETCH_MAX_BYTES) {
const { value, done } = await reader.read();
if (done) break;
bytesRead += value.byteLength;
buffer += decoder.decode(value, { stream: true });
if (/<\/head>/i.test(buffer)) break;
}
} finally {
try {
await reader.cancel();
} catch {
/* ignore */
}
}
const headMatch = buffer.match(//i);
const haystack = headMatch ? headMatch[0] : buffer;
const linkRe = /]*\bhref\s*=\s*["'](at:\/\/[^"']+)["'][^>]*>/gi;
let m: RegExpExecArray | null;
while ((m = linkRe.exec(haystack)) !== null) {
const href = m[1];
if (href.startsWith('at://')) return href;
}
return null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}