IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

proxy.ts

Buka raw
import {
  AUTH_SESSION_COOKIE_NAME,
  isSessionExpired,
  parseSessionToken,
} from "@/lib/shared/auth/session";
import { type NextRequest, NextResponse } from "next/server";

const DEMO_MODE = process.env.DEMO_MODE === "true";
const DEMO_BLOCKED_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);

const PUBLIC_ROUTES = new Set(["/login", "/register"]);
const RECOVERY_ROUTES = new Set(["/updating"]);
// Whether any account exists changes once in an install's life, at the first
// registration, so a "yes" is kept for a minute. A "no" is kept only briefly:
// the first account must be seen at once, or its owner would be sent back to
// /register with their new session cleared.
const AUTH_STATUS_WITH_USERS_CACHE_MS = 60_000;
const AUTH_STATUS_WITHOUT_USERS_CACHE_MS = 5_000;
let authStatusCache:
  | {
      hasUsers: boolean;
      expiresAt: number;
    }
  | null = null;
// Visitors arriving together share one lookup. Each lookup is an HTTP request
// back into this server and a database query; one per visitor exhausted the
// connection pool under load.
let authStatusInFlight: Promise<boolean> | null = null;

export function resetAuthStatusCacheForTests() {
  authStatusCache = null;
  authStatusInFlight = null;
}

function isPublicApiRoute(pathname: string) {
  return (
    pathname === "/api/health" ||
    pathname === "/api/auth/login" ||
    pathname === "/api/auth/register" ||
    pathname === "/api/auth/status" ||
    // Second leg of the TOTP login flow. Caller has no session cookie yet —
    // only a short-lived partial-auth token. Route handler validates that
    // token itself; do not gate it on a session.
    pathname === "/api/v1/auth/login/totp" ||
    pathname === "/api/v1/logs"
  );
}

function isStaticRoute(pathname: string) {
  return (
    pathname.startsWith("/_next/") ||
    pathname.startsWith("/images/") ||
    pathname === "/favicon.ico" ||
    pathname === "/icon.svg" ||
    pathname === "/apple-icon.png" ||
    /\.[a-zA-Z0-9]+$/.test(pathname)
  );
}

async function signPayloadEdge(payload: string, secret: string) {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

  const signatureBuffer = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(payload),
  );

  return Array.from(new Uint8Array(signatureBuffer))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

function safeEqual(left: string, right: string) {
  if (left.length !== right.length) return false;

  let mismatch = 0;
  for (let i = 0; i < left.length; i += 1) {
    mismatch |= left.charCodeAt(i) ^ right.charCodeAt(i);
  }

  return mismatch === 0;
}

export async function verifySessionTokenInMiddleware(
  sessionToken: string | undefined,
  secret = process.env.AUTH_SESSION_SECRET ?? "dev-session-secret-change-me",
) {
  if (!sessionToken) return false;

  const parsed = parseSessionToken(sessionToken);
  if (!parsed) return false;
  if (isSessionExpired(parsed.expiresAtEpochSeconds)) return false;

  const expected = await signPayloadEdge(parsed.payload, secret);
  return safeEqual(expected, parsed.signature);
}

async function hasUsersInDb(request: NextRequest) {
  if (authStatusCache && authStatusCache.expiresAt > Date.now()) {
    return authStatusCache.hasUsers;
  }

  authStatusInFlight ??= lookUpHasUsers(request).finally(() => {
    authStatusInFlight = null;
  });
  return authStatusInFlight;
}

async function lookUpHasUsers(request: NextRequest) {
  try {
    const response = await fetch(new URL("/api/auth/status", request.url), {
      method: "GET",
      cache: "no-store",
    });

    if (!response.ok) {
      throw new Error("Auth status request failed");
    }

    const json = (await response.json()) as {
      data?: {
        hasUsers?: boolean;
      };
    };

    const hasUsers = typeof json.data?.hasUsers === "boolean" ? json.data.hasUsers : false;
    authStatusCache = {
      hasUsers,
      expiresAt:
        Date.now() + (hasUsers ? AUTH_STATUS_WITH_USERS_CACHE_MS : AUTH_STATUS_WITHOUT_USERS_CACHE_MS),
    };
    return hasUsers;
  } catch {
    if (authStatusCache) {
      return authStatusCache.hasUsers;
    }

    // Not knowing is not the same as knowing there is nobody. Answering `false`
    // here says "fresh install": the visitor is sent to registration, and an
    // authenticated one has their session cookie cleared on the way — a
    // momentary database or network hiccup logging everyone out and offering
    // the machine up for registration. Assuming accounts exist costs a real
    // fresh install one redirect to /login; the other way costs a running
    // install its sessions.
    return true;
  }
}

function getAuthEntryPath(hasUsers: boolean) {
  return hasUsers ? "/login" : "/register";
}

function clearSessionCookie(response: NextResponse) {
  response.cookies.set({
    name: AUTH_SESSION_COOKIE_NAME,
    value: "",
    path: "/",
    expires: new Date(0),
  });
  return response;
}

/**
 * Nothing this middleware touches may sit in a shared cache.
 *
 * Homeio is commonly published through a tunnel or a CDN — the docs recommend
 * exactly that — and a cache in front that keeps redirects will serve one
 * visitor's answer to everybody. An install that was briefly empty answers
 * `307 -> /register`; once that is cached, every later visitor is sent to
 * registration no matter how many accounts exist, and the session cookie they
 * just earned is thrown away on the way back. Incognito does not help, nor
 * does another browser or another device: the cache is upstream of all of them,
 * which is exactly what makes it look like a server-side bug.
 *
 * Immutable assets are unaffected — `_next/static` never reaches this
 * middleware, by the matcher below — so they keep the long-lived caching that
 * makes them worth caching.
 */
function withoutSharedCaching(response: NextResponse) {
  response.headers.set("Cache-Control", "private, no-store");
  return response;
}

export async function proxy(request: NextRequest) {
  const response = await route(request);

  // Static files are the exception, and the only one: they carry no session,
  // they change name when they change content, and caching them is the whole
  // point of putting a CDN in front of anything.
  return isStaticRoute(request.nextUrl.pathname) ? response : withoutSharedCaching(response);
}

async function route(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  if (isStaticRoute(pathname)) {
    return NextResponse.next();
  }

  if (isPublicApiRoute(pathname)) {
    return NextResponse.next();
  }

  if (
    DEMO_MODE &&
    DEMO_BLOCKED_METHODS.has(request.method) &&
    pathname.startsWith("/api/v1/")
  ) {
    return NextResponse.json(
      { error: "This action is not available in demo mode." },
      { status: 403 },
    );
  }

  const sessionToken = request.cookies.get(AUTH_SESSION_COOKIE_NAME)?.value;
  const isAuthenticated = await verifySessionTokenInMiddleware(sessionToken);

  if (PUBLIC_ROUTES.has(pathname)) {
    const hasUsers = await hasUsersInDb(request);
    const expectedPublicPath = getAuthEntryPath(hasUsers);

    if (isAuthenticated) {
      if (!hasUsers) {
        if (pathname === "/register") {
          return clearSessionCookie(NextResponse.next());
        }

        return clearSessionCookie(
          NextResponse.redirect(new URL("/register", request.url)),
        );
      }

      return NextResponse.redirect(new URL("/", request.url));
    }

    if (pathname !== expectedPublicPath) {
      return NextResponse.redirect(new URL(expectedPublicPath, request.url));
    }

    return NextResponse.next();
  }

  if (RECOVERY_ROUTES.has(pathname)) {
    return NextResponse.next();
  }

  if (isAuthenticated) {
    const hasUsers = await hasUsersInDb(request);

    if (!hasUsers) {
      if (pathname.startsWith("/api/")) {
        if (pathname === "/api/auth/me") {
          const response = NextResponse.json(
            {
              error: "Unauthorized",
              redirectTo: "/register",
            },
            { status: 401 },
          );
          response.headers.set("x-auth-entry", "/register");
          return clearSessionCookie(response);
        }

        return clearSessionCookie(
          NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
        );
      }

      return clearSessionCookie(
        NextResponse.redirect(new URL("/register", request.url)),
      );
    }
  }

  if (!isAuthenticated) {
    if (pathname.startsWith("/api/")) {
      if (pathname === "/api/auth/me") {
        const hasUsers = await hasUsersInDb(request);
        const authEntry = getAuthEntryPath(hasUsers);
        const response = NextResponse.json(
          {
            error: "Unauthorized",
            redirectTo: authEntry,
          },
          { status: 401 },
        );
        response.headers.set("x-auth-entry", authEntry);
        return response;
      }

      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const hasUsers = await hasUsersInDb(request);
    const authUrl = new URL(getAuthEntryPath(hasUsers), request.url);
    authUrl.searchParams.set(
      "next",
      `${request.nextUrl.pathname}${request.nextUrl.search}`,
    );
    return NextResponse.redirect(authUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
byte
9.579
baris
317
Jenis
ts
Commit terakhir
16 Oktober 2026