mirror of
https://github.com/jakejarvis/hoot.git
synced 2025-10-18 14:24:26 -04:00
132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { NextResponse } from "next/server";
|
|
import { toRegistrableDomain } from "@/lib/domain-server";
|
|
|
|
// Matches beginning "http:" or "https:" followed by any number of slashes, e.g.:
|
|
// "https://", "https:/", "https:////" etc.
|
|
// Then captures everything up to the next slash as the authority.
|
|
const HTTP_PREFIX_CAPTURE_AUTHORITY = /^https?:[:/]+([^/]+)/i;
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const path = request.nextUrl.pathname;
|
|
|
|
// Fast path: only act on non-root paths
|
|
if (path.length <= 1) {
|
|
return NextResponse.next({
|
|
headers: {
|
|
"x-middleware-decision": "ignore",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Remove the leading "/" so we can inspect the raw string the user pasted after the host
|
|
const afterSlash = path.slice(1);
|
|
|
|
// Decode once if possible; fall back to raw on failure
|
|
let candidate = afterSlash;
|
|
try {
|
|
candidate = decodeURIComponent(afterSlash);
|
|
} catch {
|
|
// ignore decoding failures; fall back to raw
|
|
}
|
|
|
|
// If the candidate contains a scheme, extract authority; otherwise normalize the raw candidate the same way
|
|
const match = candidate.match(HTTP_PREFIX_CAPTURE_AUTHORITY);
|
|
let authority = match ? match[1] : candidate;
|
|
|
|
// Strip any query or fragment that may be present
|
|
const queryIndex = authority.indexOf("?");
|
|
const fragmentIndex = authority.indexOf("#");
|
|
let cutoffIndex = -1;
|
|
if (queryIndex !== -1 && fragmentIndex !== -1) {
|
|
cutoffIndex = Math.min(queryIndex, fragmentIndex);
|
|
} else {
|
|
cutoffIndex = queryIndex !== -1 ? queryIndex : fragmentIndex;
|
|
}
|
|
if (cutoffIndex !== -1) authority = authority.slice(0, cutoffIndex);
|
|
|
|
// For scheme-less inputs, drop any path portion after the first slash
|
|
if (!match) {
|
|
const slashIndex = authority.indexOf("/");
|
|
if (slashIndex !== -1) authority = authority.slice(0, slashIndex);
|
|
}
|
|
|
|
authority = authority.trim();
|
|
|
|
// Remove userinfo if present
|
|
const atIndex = authority.lastIndexOf("@");
|
|
if (atIndex !== -1) authority = authority.slice(atIndex + 1);
|
|
|
|
// Detect bracketed IPv6 literal and only strip port if a colon appears after the closing ']'.
|
|
if (authority.startsWith("[")) {
|
|
const closingBracketIndex = authority.indexOf("]");
|
|
if (closingBracketIndex !== -1) {
|
|
const colonAfterBracketIndex = authority.indexOf(
|
|
":",
|
|
closingBracketIndex + 1,
|
|
);
|
|
if (colonAfterBracketIndex !== -1) {
|
|
authority = authority.slice(0, colonAfterBracketIndex);
|
|
} else {
|
|
// keep the bracketed host intact when no port is present
|
|
authority = authority.slice(0, closingBracketIndex + 1);
|
|
}
|
|
} else {
|
|
// Malformed bracket: fall back to first colon behavior
|
|
const colonIndex = authority.indexOf(":");
|
|
if (colonIndex !== -1) authority = authority.slice(0, colonIndex);
|
|
}
|
|
} else {
|
|
const colonIndex = authority.indexOf(":");
|
|
if (colonIndex !== -1) authority = authority.slice(0, colonIndex);
|
|
}
|
|
|
|
candidate = authority.trim();
|
|
|
|
if (!candidate) {
|
|
return NextResponse.next({
|
|
headers: {
|
|
"x-middleware-decision": "ignore",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Determine registrable apex and subdomain presence
|
|
const registrable = toRegistrableDomain(candidate);
|
|
if (!registrable) {
|
|
return NextResponse.next({
|
|
headers: {
|
|
"x-middleware-decision": "ignore",
|
|
},
|
|
});
|
|
}
|
|
|
|
// If coming from a full URL carrier, any subdomain is present, or the host differs from registrable (case/trailing dot), redirect to apex
|
|
const shouldRedirectToApex = Boolean(match) || candidate !== registrable;
|
|
if (shouldRedirectToApex) {
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = `/${encodeURIComponent(registrable)}`;
|
|
url.search = "";
|
|
url.hash = "";
|
|
return NextResponse.redirect(url, {
|
|
headers: {
|
|
"x-middleware-decision": "redirect",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Otherwise, it's already a bare registrable domain — proceed
|
|
return NextResponse.next({
|
|
headers: {
|
|
"x-middleware-decision": "ok",
|
|
},
|
|
});
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Exclude API and Next internals/static assets for performance and to avoid side effects
|
|
"/((?!api|_next/static|_next/image|_next/webpack-hmr|_vercel|_proxy|favicon.ico|robots.txt|sitemap.xml).*)",
|
|
],
|
|
};
|