/** * HTTP Basic Auth gate for the entire UI. * * Username is read from `UI_USERNAME` (defaults to "etiya") and password from * `UI_PASSWORD`. Both are server-only env vars set as HF Space secrets — they * are never inlined into the client bundle. * * The browser's native credentials prompt handles the UX. After the user * authenticates once, the browser caches the credentials per-origin and * sends them automatically on every subsequent request to this realm. */ import { NextRequest, NextResponse } from "next/server"; export const config = { matcher: ["/((?!_next/static|_next/image|_next/data|favicon.ico).*)"], }; const REALM = 'Basic realm="Etiya BSS Atelier", charset="UTF-8"'; export function middleware(req: NextRequest) { const password = process.env.UI_PASSWORD; const username = process.env.UI_USERNAME || "etiya"; // Fail-open if no password configured — useful for local dev with no setup. if (!password) return NextResponse.next(); const auth = req.headers.get("authorization"); if (!auth?.startsWith("Basic ")) { return unauthorised(); } let user = ""; let pass = ""; try { const decoded = atob(auth.slice("Basic ".length)); const colon = decoded.indexOf(":"); if (colon >= 0) { user = decoded.slice(0, colon); pass = decoded.slice(colon + 1); } } catch { return unauthorised(); } if ( !constantTimeEqual(user, username) || !constantTimeEqual(pass, password) ) { return unauthorised(); } return NextResponse.next(); } function unauthorised() { return new NextResponse("Authentication required.", { status: 401, headers: { "WWW-Authenticate": REALM, "Content-Type": "text/plain; charset=utf-8", // Discourage caching the 401 response so re-prompts behave predictably. "Cache-Control": "no-store", }, }); } function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; }