openreader/src/middleware.ts
Richard R bc4c888866
Some checks failed
Create and publish Docker images / prepare (push) Has been cancelled
Version Docs on Tag / version-docs (push) Has been cancelled
Create and publish Docker images / build (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Create and publish Docker images / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images / merge (push) Has been cancelled
feat: implement US-only availability check for production instance in middleware and privacy page
2026-02-19 12:28:30 -07:00

146 lines
4.3 KiB
TypeScript

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* Better Auth session cookie name (default prefix + session_token).
* @see https://www.better-auth.com/docs/concepts/session-management
*/
const SESSION_COOKIE = 'better-auth.session_token';
const SECURE_SESSION_COOKIE = '__Secure-better-auth.session_token';
const SESSION_COOKIE_NAMES = [SESSION_COOKIE, SECURE_SESSION_COOKIE];
const US_COUNTRY_CODE = 'US';
const COUNTRY_HEADER_NAMES = [
'x-vercel-ip-country',
'cf-ipcountry',
'x-country-code',
'x-geo-country',
];
/**
* Routes that never require a session cookie.
* Static assets and Next.js internals are excluded via the matcher config below.
*/
const PUBLIC_PATH_PREFIXES = [
'/api/auth', // Better Auth endpoints (sign-in, sign-up, callbacks, etc.)
'/signin',
'/signup',
'/privacy',
];
function isPublicPath(pathname: string): boolean {
// Root landing page
if (pathname === '/') return true;
return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
function isAuthEnabled(): boolean {
return !!(process.env.AUTH_SECRET && process.env.BASE_URL);
}
function isAnonymousAuthEnabled(): boolean {
if (!isAuthEnabled()) return false;
const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
return raw?.trim().toLowerCase() === 'true';
}
function isRichardrDevProductionInstance(): boolean {
return process.env.RICHARDRDEV_PRODUCTION?.trim().toLowerCase() === 'true';
}
function getCountryCodeFromHeaders(request: NextRequest): string | null {
for (const headerName of COUNTRY_HEADER_NAMES) {
const rawValue = request.headers.get(headerName);
if (!rawValue) continue;
const normalized = rawValue.trim().toUpperCase();
if (normalized) {
return normalized;
}
}
return null;
}
export function middleware(request: NextRequest) {
if (isRichardrDevProductionInstance()) {
const countryCode = getCountryCodeFromHeaders(request);
const isUnitedStatesRequest = countryCode === US_COUNTRY_CODE;
// Strict region gate for the official production instance.
if (!isUnitedStatesRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ error: 'OpenReader is only available in the United States.' },
{ status: 451, headers: { 'Cache-Control': 'no-store' } },
);
}
return new NextResponse('OpenReader is only available in the United States.', {
status: 451,
headers: {
'Cache-Control': 'no-store',
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
}
// When auth is disabled entirely, let everything through.
if (!isAuthEnabled()) {
return NextResponse.next();
}
const { pathname } = request.nextUrl;
// Fast-path redirect for signed-in users hitting the public landing page.
// This avoids extra server work in the landing page render path.
if (pathname === '/' && request.nextUrl.searchParams.get('redirect') !== 'false') {
const hasSession = SESSION_COOKIE_NAMES.some((name) => request.cookies.has(name));
if (hasSession) {
const appUrl = request.nextUrl.clone();
appUrl.pathname = '/app';
appUrl.search = '';
return NextResponse.redirect(appUrl);
}
}
// Public routes are always accessible.
if (isPublicPath(pathname)) {
return NextResponse.next();
}
// When anonymous auth is enabled, unauthenticated users need to reach
// the page so AuthLoader.tsx can bootstrap an anonymous session client-side.
if (isAnonymousAuthEnabled()) {
return NextResponse.next();
}
// Check for the presence of a session cookie.
const hasSession = SESSION_COOKIE_NAMES.some((name) => request.cookies.has(name));
if (!hasSession) {
// API routes get a 401 instead of a redirect.
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Page routes redirect to sign-in.
const signInUrl = request.nextUrl.clone();
signInUrl.pathname = '/signin';
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
}
/**
* Match all routes except static assets and Next.js internals.
*/
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon\\.ico|icon\\.png|icon\\.svg|apple-icon\\.png|manifest\\.json).*)',
],
};