mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* chore(release): v3.2.8 — Docker auto-update UI and cache analytics fixes * fix(sse): remove race condition in cache metrics tracking (#758) - Remove in-memory metrics tracking (currentMetrics, trackCacheMetrics, updateCacheMetrics) - Cache metrics now computed on-the-fly from usage_history table (single source of truth) - Fixes CRITICAL issue from code review: concurrent requests overwriting metrics - Fixes WARNING: duplicate metric tracking logic in streaming/non-streaming paths Ref: PR #752 (merged before this fix was included) * fix: handle allRateLimited credentials & forward extra body keys in embeddings/images routes (#757) * fix: handle allRateLimited credentials in embeddings and images routes When getProviderCredentials() returns an allRateLimited object (truthy, but without apiKey/accessToken), the embeddings and images routes incorrectly passed it to handlers as valid credentials. The handlers then sent upstream requests without Authorization headers, causing 401 errors from providers (e.g. NVIDIA NIM). This only manifested under concurrent requests: a chat/completions call could trigger rate limiting on a provider account, and a simultaneous embeddings request would receive the allRateLimited sentinel — but treat it as valid credentials. The chat pipeline already handled this case correctly. This commit adds the same allRateLimited guard to all affected routes: - POST /v1/embeddings - POST /v1/providers/{provider}/embeddings - POST /v1/images/generations - POST /v1/providers/{provider}/images/generations Also adds a defense-in-depth guard in the embeddings handler itself: if no auth token is available for a non-local provider, return 401 immediately instead of sending an unauthenticated request upstream. Made-with: Cursor * fix(embeddings): forward extra body keys to upstream providers The embeddings handler only forwarded model, input, dimensions, and encoding_format to upstream providers, silently dropping any additional fields. This broke asymmetric embedding APIs (e.g. NVIDIA NIM nv-embedqa-e5-v5) that require input_type, and other providers expecting user or truncate parameters. Add a KNOWN_FIELDS exclusion set and forward all unrecognized body keys to the upstream request, matching the passthrough pattern used by the chat pipeline's DefaultExecutor.transformRequest(). Made-with: Cursor * fix(auth): redirect and unconditional 401 on disabled requireLogin + fix test cases * fix(build): remove legacy proxy.ts causing Next.js build collision * fix(build): revert middleware.ts rename to proxy.ts because of Next.js Edge constraints --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: tombii <tombii@users.noreply.github.com> Co-authored-by: Gorchakov-Pressure <117600961+Gorchakov-Pressure@users.noreply.github.com>
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { jwtVerify, SignJWT } from "jose";
|
|
import { generateRequestId } from "./shared/utils/requestId";
|
|
import { getSettings } from "./lib/localDb";
|
|
import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth";
|
|
import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard";
|
|
import { isDraining } from "./lib/gracefulShutdown";
|
|
import { isModelSyncInternalRequest } from "./shared/services/modelSyncScheduler";
|
|
|
|
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "");
|
|
|
|
export async function proxy(request: any) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Pipeline: Add request ID header for end-to-end tracing
|
|
const requestId = generateRequestId();
|
|
const response = NextResponse.next();
|
|
response.headers.set("X-Request-Id", requestId);
|
|
|
|
// ──────────────── Pre-flight: Reject during shutdown drain ────────────────
|
|
if (isDraining() && pathname.startsWith("/api/")) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: "SERVICE_UNAVAILABLE",
|
|
message: "Server is shutting down",
|
|
correlation_id: requestId,
|
|
},
|
|
},
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
|
|
// ──────────────── Pre-flight: Reject oversized bodies ────────────────
|
|
if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") {
|
|
const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname));
|
|
if (bodySizeRejection) return bodySizeRejection;
|
|
}
|
|
|
|
// ──────────────── Protect Management API Routes ────────────────
|
|
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) {
|
|
// Allow public routes (login, logout, health, etc.)
|
|
if (isPublicRoute(pathname)) {
|
|
return response;
|
|
}
|
|
|
|
// Allow the model auto-sync scheduler to reach only its internal provider routes.
|
|
if (
|
|
isModelSyncInternalRequest(request) &&
|
|
/^\/api\/providers\/[^/]+\/(sync-models|models)$/.test(pathname)
|
|
) {
|
|
return response;
|
|
}
|
|
|
|
// Check if auth is required at all (respects requireLogin setting)
|
|
const authRequired = await isAuthRequired();
|
|
if (!authRequired) {
|
|
return response;
|
|
}
|
|
|
|
// Verify authentication (JWT cookie or Bearer API key)
|
|
const authError = await verifyAuth(request);
|
|
if (authError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: "AUTH_001",
|
|
message: authError,
|
|
correlation_id: requestId,
|
|
},
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// ──────────────── Protect Dashboard Routes ────────────────
|
|
if (pathname.startsWith("/dashboard")) {
|
|
// Always allow onboarding — it has its own setupComplete guard
|
|
if (pathname.startsWith("/dashboard/onboarding")) {
|
|
return response;
|
|
}
|
|
|
|
const token = request.cookies.get("auth_token")?.value;
|
|
|
|
if (token) {
|
|
try {
|
|
const { payload } = await jwtVerify(token, SECRET);
|
|
|
|
// Auto-refresh: if token expires within 7 days, issue a fresh 30-day token
|
|
const exp = payload.exp as number;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const REFRESH_WINDOW = 7 * 24 * 60 * 60; // 7 days in seconds
|
|
if (exp && exp - now < REFRESH_WINDOW) {
|
|
try {
|
|
const freshToken = await new SignJWT({ authenticated: true })
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime("30d")
|
|
.sign(SECRET);
|
|
|
|
// Detect secure context
|
|
const fwdProto = (request.headers.get("x-forwarded-proto") || "")
|
|
.split(",")[0]
|
|
.trim()
|
|
.toLowerCase();
|
|
const isHttps = fwdProto === "https" || request.nextUrl?.protocol === "https:";
|
|
const useSecure = process.env.AUTH_COOKIE_SECURE === "true" || isHttps;
|
|
|
|
response.cookies.set("auth_token", freshToken, {
|
|
httpOnly: true,
|
|
secure: useSecure,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
});
|
|
console.log(
|
|
`[Middleware] JWT auto-refreshed for ${pathname} (was expiring in ${Math.round((exp - now) / 3600)}h)`
|
|
);
|
|
} catch (refreshErr) {
|
|
// Refresh failed — continue with existing valid token
|
|
console.error("[Middleware] JWT auto-refresh failed:", refreshErr.message);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
} catch (err) {
|
|
// FASE-01: Log auth errors instead of silently redirecting
|
|
console.error("[Middleware] auth_error: JWT verification failed:", err.message, {
|
|
path: pathname,
|
|
tokenPresent: true,
|
|
requestId,
|
|
});
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Direct import — no HTTP self-fetch overhead
|
|
const settings = await getSettings();
|
|
// Skip auth if login is not required
|
|
if (settings.requireLogin === false) {
|
|
return response;
|
|
}
|
|
// Skip auth ONLY for fresh installs (before onboarding) where no password exists yet.
|
|
// Once setupComplete is true, always require auth — prevents bypass if password row is lost (#151)
|
|
if (!settings.setupComplete && !settings.password && !process.env.INITIAL_PASSWORD) {
|
|
return response;
|
|
}
|
|
} catch (err) {
|
|
// FASE-01: Log settings fetch errors instead of silencing them
|
|
console.error("[Middleware] settings_error: Settings read failed:", err.message, {
|
|
path: pathname,
|
|
requestId,
|
|
});
|
|
// On error, require login
|
|
}
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
|
|
// Redirect / to /dashboard if logged in, or /dashboard if it's the root
|
|
if (pathname === "/") {
|
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/", "/dashboard/:path*", "/api/:path*"],
|
|
};
|